Initial import: keyrelay, a WiFi-controlled USB HID keyboard

Browser or terminal controller drives an ESP32-S3 that acts as a Logitech
G815 into a target machine running no software. Disarmed by default; arming
requires the passphrase, the board button, or the serial console, and every
fail-safe path cancels in-flight work.

- src/: firmware with pure, host-testable modules (keystate, proto, text,
  ws_codec, sha1), the WebSocket control server, and the served page
- tests/: host suites for the pure modules, relayctl parity, and page JS
- tools/: relayctl terminal controller, serial console, bootloader helper,
  and an evdev target watcher
- platformio.ini: bridge (USB HID) and console (no-HID) environments
- README.md: single source of truth for behaviour, protocol and safety

LLM-Generated: true
This commit is contained in:
2026-11-08 00:20:33 -05:00
commit 9bc8d5213c
27 changed files with 8243 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Host tests for keyrelay's pure logic modules.
#
# tests/run_tests.sh build and run every suite
# tests/run_tests.sh clean remove the build directory
#
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/ws_codec.h,
# src/sha1.h) have no Arduino or ESP-IDF dependency by design, so the exact code that
# runs on the board is compiled and exercised here before anything is flashed.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
# g++ on PATH native, fastest
# a local container image podman + the first image that has g++ (see images
# below), with this repo bind-mounted read-write into
# /work -- no system packages installed for a test run
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
out="$here/build"
if [[ "${1:-}" == "clean" ]]; then
rm -rf "$out"
echo "cleaned $out"
exit 0
fi
# Candidate images, tried in order, that already exist locally. Add your own here.
images=(
"localhost/ai-sandbox:ubuntu-9a08b1d500b0"
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_ws_codec)
build_and_run() {
local cxx="$1"
local prefix=("$cxx")
local flags=(-std=gnu++17 -O1 -g -Wall -Wextra -Wno-unused-function -Isrc -Itests)
mkdir -p "$out"
if [[ -n "${SANITIZE:-}" ]]; then
flags+=(-fsanitize=address,undefined -fno-omit-frame-pointer)
echo "sanitizers: on"
fi
local failed=0 suite
for suite in "${suites[@]}"; do
if ! "${prefix[@]}" "${flags[@]}" "tests/$suite.cpp" -o "$out/$suite" \
2>"$out/$suite.log"; then
echo "BUILD FAILED: $suite"
cat "$out/$suite.log"
failed=1
continue
fi
[[ -s "$out/$suite.log" ]] && cat "$out/$suite.log"
"$out/$suite" || failed=1
done
# The Python side (the CLI client's WebSocket codec, and its key table against
# the one embedded in the controller page) needs only stdlib.
if command -v python3 >/dev/null 2>&1; then
python3 tests/test_relayctl.py || failed=1
else
echo "python3 not found: skipped tests/test_relayctl.py"
fi
return $failed
}
# The controller page is a C++ raw string, so no compiler looks at its JavaScript and
# a typo there fails in somebody's browser instead of in a build. Node runs it -- on
# the host, deliberately, because the container that supplies g++ need not have node.
run_page_suite() {
if command -v node >/dev/null 2>&1; then
node "$here/test_page.js" || return 1
else
echo "node not found: skipped tests/test_page.js"
fi
return 0
}
if [[ "${1:-}" == "--inside" ]]; then
cd "$root" || exit 2
# The container has g++ but not necessarily node; the page suite skips itself.
build_and_run "${CXX:-g++}"
rc=$?
if [[ -z "${KR_QUIET_RESULT:-}" ]]; then
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
fi
exit $rc
fi
if [[ -n "${CXX:-}" ]]; then
echo "compiler: \$CXX = $CXX"
(cd "$root" && build_and_run "$CXX")
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v g++ >/dev/null 2>&1; then
echo "compiler: $(command -v g++)"
(cd "$root" && build_and_run g++)
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v podman >/dev/null 2>&1; then
for img in "${images[@]}"; do
if ! timeout 60 podman image exists "$img" 2>/dev/null; then
continue
fi
if ! timeout 60 podman run --rm --entrypoint sh "$img" -c 'command -v g++' \
>/dev/null 2>&1; then
continue
fi
echo "compiler: g++ inside $img (repo mounted at /work)"
# :z relabels for SELinux; the build output lands in tests/build as usual.
# KR_QUIET_RESULT: the host prints the one summary, after its own page suite.
podman run --rm --entrypoint bash -v "$root:/work:z" \
-e "SANITIZE=${SANITIZE:-}" -e KR_QUIET_RESULT=1 \
"$img" /work/tests/run_tests.sh --inside
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; exit 1; fi
echo "RESULT: all suites passed"
exit 0
done
fi
cat >&2 <<EOF
No C++ compiler found.
install one e.g. sudo dnf install gcc-c++ (then re-run)
or set \$CXX e.g. CXX=/path/to/g++ tests/run_tests.sh
or add an image add a local podman image with g++ to images[] above
EOF
exit 2
+49
View File
@@ -0,0 +1,49 @@
// Minimal assertion harness for the host tests. No dependencies, prints the
// failing expression with file:line, and exits non-zero on any failure so
// tests/run_tests.sh is usable as a gate.
#pragma once
#include <cstdio>
#include <cstring>
static int g_checks = 0;
static int g_failures = 0;
static const char *g_section = "unset";
#define CASE(name) (g_section = (name))
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \
g_section, #cond); \
} \
} while (0)
#define CHECK_EQ(actual, expected) \
do { \
g_checks++; \
const long long _a = (long long)(actual), _e = (long long)(expected); \
if (_a != _e) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s == %s : got %lld, want %lld\n", __FILE__, \
__LINE__, g_section, #actual, #expected, _a, _e); \
} \
} while (0)
#define CHECK_STR(actual, expected) \
do { \
g_checks++; \
if (std::strcmp((actual), (expected)) != 0) { \
g_failures++; \
std::printf(" FAIL %s:%d [%s] %s == %s : got \"%s\", want \"%s\"\n", \
__FILE__, __LINE__, g_section, #actual, #expected, (actual), \
(expected)); \
} \
} while (0)
static int report(const char *suite) {
std::printf("%-24s %3d checks, %d failures\n", suite, g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}
+202
View File
@@ -0,0 +1,202 @@
// tests for the key-state tracker: tests/test_keystate.cpp
//
// The properties under test are the ones that decide whether the target machine
// ends up with a key stuck down: idempotence, transition preservation, rollover,
// and that clear() really clears.
#include <cstring>
#include "keystate.h"
#include "test.h"
using kr::KeyboardReport;
using kr::KeyboardState;
namespace {
// A report as 8 hex-byte triples, for readable assertions.
void hexreport(const KeyboardReport &r, char out[32]) {
static const char kHex[] = "0123456789abcdef";
const uint8_t bytes[8] = {r.modifiers, r.reserved, r.keys[0], r.keys[1], r.keys[2],
r.keys[3], r.keys[4], r.keys[5]};
for (int i = 0; i < 8; i++) {
out[3 * i] = kHex[bytes[i] >> 4];
out[3 * i + 1] = kHex[bytes[i] & 0xF];
out[3 * i + 2] = i == 7 ? '\0' : ' ';
}
out[23] = '\0';
}
} // namespace
int main() {
char h1[32], h2[32];
CASE("empty state is all zeroes");
{
KeyboardState s;
CHECK(!s.dirty());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
}
CASE("reserved byte is always zero");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA};
s.apply(kr::kModLctrl | kr::kModRgui, keys, 1);
CHECK_EQ(s.report().reserved, 0);
CHECK_EQ(s.report().modifiers, kr::kModLctrl | kr::kModRgui);
}
CASE("apply is idempotent -- same state twice dirties nothing");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageZ};
s.apply(kr::kModLshift, keys, 2);
CHECK(s.dirty());
s.ackDirty();
s.apply(kr::kModLshift, keys, 2);
CHECK(!s.dirty());
s.apply(kr::kModLshift, keys, 2);
CHECK(!s.dirty());
}
CASE("duplicates collapse, order is preserved");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageA, kr::kUsageZ};
s.apply(0, keys, 3);
CHECK_EQ(s.held(), 2u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 04 1d 00 00 00 00");
CHECK(s.isDown(kr::kUsageA));
CHECK(s.isDown(kr::kUsageZ));
}
CASE("usage 0x00 is not a key");
{
KeyboardState s;
const uint8_t keys[] = {0x00, kr::kUsageEnter, 0x00};
s.apply(0, keys, 3);
CHECK_EQ(s.held(), 1u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 28 00 00 00 00 00");
}
CASE("a distinct transition always produces a distinct report");
{
// A fast tap: press then release. Both states have to be reportable, or the
// keypress vanishes and the target never sees the character.
KeyboardState s;
const uint8_t down[] = {kr::kUsageA};
s.apply(0, down, 1);
CHECK(s.dirty());
s.ackDirty();
const uint8_t none = 0;
s.apply(0, &none, 0);
CHECK(s.dirty());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
}
CASE("modifiers alone are a reportable change");
{
KeyboardState s;
s.apply(kr::kModLalt, nullptr, 0);
CHECK(s.dirty());
CHECK_EQ(s.modifiers(), kr::kModLalt);
s.apply(0, nullptr, 0);
CHECK(s.dirty());
}
CASE("six keys is exactly the limit");
{
KeyboardState s;
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
s.apply(0, keys, 6);
CHECK(!s.rollover());
CHECK_EQ(s.held(), 6u);
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 04 05 06 07 08 09");
}
CASE("a seventh key reports ErrorRollOver in every slot");
{
KeyboardState s;
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
s.apply(0, keys, 7);
CHECK(s.rollover());
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 01 01 01 01 01 01");
// ... and releasing back under the limit reports the real keys again, so the
// host is never left with a phantom key held down.
const uint8_t six[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
s.apply(0, six, 6);
CHECK(!s.rollover());
hexreport(s.report(), h2);
CHECK_STR(h2, "00 00 04 05 06 07 08 09");
}
CASE("more keys than we track still cannot wedge the state");
{
KeyboardState s;
uint8_t keys[40];
for (size_t i = 0; i < sizeof(keys); i++) keys[i] = static_cast<uint8_t>(0x04 + (i % 0x1A));
s.apply(0xFF, keys, sizeof(keys));
CHECK(s.rollover());
CHECK(s.held() <= kr::kMaxTrackedKeys);
s.clear();
CHECK_EQ(s.held(), 0u);
CHECK_EQ(s.modifiers(), 0);
}
CASE("clear releases everything; a repeat clear adds nothing");
{
KeyboardState s;
const uint8_t keys[] = {kr::kUsageA, kr::kUsageSpace};
s.apply(kr::kModLctrl | kr::kModLshift, keys, 2);
s.ackDirty();
s.clear();
CHECK(s.dirty()); // the all-keys-up report still has to go out
hexreport(s.report(), h1);
CHECK_STR(h1, "00 00 00 00 00 00 00 00");
s.ackDirty();
s.clear();
CHECK(!s.dirty()); // nothing changed, so no pointless second report
s.apply(kr::kModLctrl, nullptr, 0);
CHECK(s.dirty()); // ... and a real change after a clear is still seen
}
CASE("makeReport builds the Ctrl+Alt+Del chord byte-exactly");
{
const uint8_t del = kr::kUsageDelForward;
const KeyboardReport r =
kr::makeReport(kr::kModLctrl | kr::kModLalt, &del, 1);
hexreport(r, h1);
// 05 = LCtrl|LAlt, 4C = "Delete Forward". This is the exact sequence Windows
// accepts as the secure attention sequence and logind sees as CAD.
CHECK_STR(h1, "05 00 4c 00 00 00 00 00");
}
CASE("makeReport rolls over on more than six keys");
{
const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
const KeyboardReport r = kr::makeReport(0, keys, 7);
CHECK_EQ(r.keys[0], kr::kUsageErrorRollover);
CHECK_EQ(r.keys[5], kr::kUsageErrorRollover);
}
CASE("reportsEqual distinguishes modifiers and slots");
{
KeyboardReport a = {0, 0, {4, 5, 0, 0, 0, 0}};
KeyboardReport b = a;
CHECK(kr::reportsEqual(a, b));
b.modifiers = kr::kModLshift;
CHECK(!kr::reportsEqual(a, b));
b = a;
b.keys[2] = 9;
CHECK(!kr::reportsEqual(a, b));
}
return report("keystate");
}
+278
View File
@@ -0,0 +1,278 @@
// tests/test_page.js -- behaviour tests for the controller page's script.
//
// node tests/test_page.js
//
// The page is the primary interface, and it lives inside a C++ raw string where no
// compiler can see it: a typo there does not fail a build, it fails silently in
// somebody's browser while a target is live. So the script is extracted from
// src/web_page.h and run against a minimal DOM shim -- enough to exercise the rules
// that matter, which are what the log is allowed to contain, which characters may
// leave the tab, and when a run of pasted text stops.
//
// It is a behaviour test, not a rendering test: no CSS, no layout, no real keyboard.
// tests/run_tests.sh skips it when node is not installed.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
// ---------------------------------------------------------------------------
// A DOM small enough to read, complete enough to run the page.
// ---------------------------------------------------------------------------
function makeEl(tagName) {
const el = {
tagName: (tagName || "div").toUpperCase(), dataset: {}, className: "",
textContent: "", innerHTML: "", value: "", checked: false, disabled: false,
readOnly: false, children: [], listeners: {},
scrollTop: 0, scrollHeight: 0,
classList: { toggle() {}, add() {}, remove() {}, contains() { return false; } },
get firstChild() { return this.children[0] || null; },
get firstElementChild() { return this.children.find(c => c.tagName) || null; },
get lastElementChild() {
const e = this.children.filter(c => c.tagName);
return e.length ? e[e.length - 1] : null;
},
get childElementCount() { return this.children.filter(c => c.tagName).length; },
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// The shim hands out an element for any id, which is far less work than a real DOM.
// The weakness in that is obvious, so it is compensated for: every id the script asks
// for is recorded and checked against the ids that are actually in the markup at the
// end. A mistyped id is a null dereference in a browser, and silence here.
const registry = new Map();
const requestedIds = new Set();
const byId = id => {
requestedIds.add(id);
if (!registry.has(id)) registry.set(id, makeEl("div"));
return registry.get(id);
};
const sentFrames = [];
const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}');
const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`);
class FakeWebSocket {
constructor() {
this.readyState = 1; // OPEN
FakeWebSocket.instances.push(this);
setTimeout(() => this.onopen && this.onopen(), 0);
}
send(text) { sentFrames.push(text); }
close() { this.readyState = 3; }
}
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const sandbox = {
document: {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener() {},
body: makeEl("body"),
hidden: false,
},
window: { addEventListener() {} },
location: { protocol: "http:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test
setTimeout, clearTimeout, Date, JSON, Math, Promise, TextEncoder, console,
Number, String, Object, Array, Buffer, parseInt,
};
// The page keeps its state in script-scope `let`s, so the harness reaches into that
// scope once, here, instead of duplicating the page's logic in the test.
const expose = `
;globalThis.__x = {
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
get typing() { return typing; },
};`;
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
const headerText = fs.readFileSync(headerPath, "utf8");
const from = headerText.indexOf("<script>");
const to = headerText.indexOf("<\/script>", from);
if (from < 0 || to < 0) {
console.error("no <script> block in " + headerPath);
process.exit(1);
}
const pageScript = headerText.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
const x = sandbox.__x;
const logEl = x.logEl;
// ---------------------------------------------------------------------------
let checks = 0, failures = 0;
const check = (cond, label) => {
checks++;
if (!cond) { failures++; console.log(" FAIL " + label); }
};
const checkEq = (a, b, label) => {
checks++;
if (a !== b) {
failures++;
console.log(` FAIL ${label}: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
logEl.children = []; // the page logs a prompt at load; start clean
x.log("");
x.log(" ");
x.log("\n\t ");
x.log(undefined);
x.log(null);
checkEq(logEl.childElementCount, 0,
"empty, whitespace-only and missing messages never render a line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "a real message renders");
x.log("first line");
x.log("first line");
checkEq(logEl.childElementCount, 1, "an identical line is not repeated");
check(/×3$/.test(logEl.lastElementChild.textContent), "the repeat is counted, not stacked");
x.log("second line");
checkEq(logEl.childElementCount, 2, "a different message still gets its own line");
for (let i = 0; i < 400; i++) x.log("flood " + i);
check(logEl.childElementCount <= 150, "the log is bounded, so it cannot grow all day");
console.log("characters");
checkEq(x.untypeableChars("hello world\n").length, 0, "plain text and newlines are fine");
checkEq(x.untypeableChars("\t\ta\t").length, 0, "tabs are fine");
checkEq(x.untypeableChars("café").join(""), "é", "an accent is caught");
checkEq(x.untypeableChars("a\u2019b").join(""), "\u2019", "a curly quote is caught");
checkEq(x.untypeableChars("\u{1f600}").length, 1, "an astral character is caught");
byId("paste").value = "line1\r\nline2";
checkEq(x.pasteText(), "line1\nline2", "CRLF becomes one Enter, not two");
byId("paste").value = "a\rb";
checkEq(x.pasteText(), "a\nb", "and so does a lone CR");
checkEq(x.hexify("hi"), "6869", "hex encoding");
checkEq(x.hexify("a\n"), "610a", "a newline is one byte on the wire");
console.log("key names in the log");
checkEq(x.describePress("KeyA"), "KeyA", "a bare key is named by its code name");
checkEq(x.describePress("Enter"), "Enter", "Enter is named");
checkEq(x.describePress("ArrowRight"), "ArrowRight", "arrows are named");
checkEq(x.inPageField({ target: { tagName: "TEXTAREA" } }), true, "the text box is the page's own");
checkEq(x.inPageField({ target: { tagName: "INPUT" } }), true, "the passphrase box is too");
checkEq(!!x.inPageField({ target: { tagName: "BODY" } }), false, "the page itself forwards");
checkEq(!!x.inPageField({ target: null }), false, "a missing target does not throw");
(async () => {
// Bring the link up the way the page does, so send()'s guard is the real guard.
byId("token").value = "hunter2";
x.connect();
await wait(5); // onopen fires, and clears `authed`
x.authed = true;
x.armed = true;
byId("trailingEnter").checked = false;
byId("stripBad").checked = false;
console.log("sending text");
byId("paste").value = "hello";
sentFrames.length = 0;
let p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "the frame goes out once the board is idle");
checkEq(frames()[0], "P=12:68656c6c6f", "carrying the text and the requested pace");
idleStatus();
await p;
checkEq(x.typing, false, "and the run reports itself finished");
// A paste longer than one frame: the board's `typing` count gates the next piece,
// so a board that is still busy must not be handed more to type.
const long = "x".repeat(300);
byId("paste").value = long;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
checkEq(frames().length, 1, "one frame out while the board is idle");
busyStatus(118);
await wait(20);
checkEq(frames().length, 1, "the board saying it is still typing holds the next frame back");
for (let i = 0; i < 20 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
const fs2 = frames();
checkEq(fs2.length, Math.ceil(300 / x.TEXT_FRAME_MAX), "300 characters is three frames");
for (const f of fs2) {
check(f.length <= 256, `every frame fits one WebSocket message (${f.length} bytes)`);
check(/^P=12:[0-9a-f]+$/.test(f), "every frame is well formed");
check(f.length <= 5 + 2 * x.TEXT_FRAME_MAX, "and none carries more than a frame's worth");
}
checkEq(fs2.map(f => f.slice(f.indexOf(":") + 1)).join("").length, 600,
"the frames between them carry every character exactly once");
checkEq(fs2.map(textOf).join(""), long, "and that is the text that was pasted");
console.log("refusing what it cannot type");
byId("paste").value = "bad\u2014text";
sentFrames.length = 0;
await x.sendPaste();
checkEq(frames().length, 0,
"an em dash refuses the whole paste -- nothing partial goes out");
byId("stripBad").checked = true;
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
for (let i = 0; i < 10 && x.typing; i++) { idleStatus(); await wait(3); }
await p;
checkEq(frames().map(textOf).join(""), "badtext", "and the opt-in drop sends the rest");
console.log("stopping");
byId("stripBad").checked = false;
byId("paste").value = "nope";
sentFrames.length = 0;
x.armed = false;
await x.sendPaste();
checkEq(frames().length, 0, "while disarmed, not one character is offered");
x.armed = true;
byId("paste").value = "y".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.abortTyping("harness: stop typing");
idleStatus();
await p;
checkEq(frames().length, 1, "a stopped run does not send the frames that were left");
checkEq(x.typing, false, "and it ends, rather than hanging");
x.armed = true;
byId("paste").value = "z".repeat(300);
sentFrames.length = 0;
p = x.sendPaste();
await wait(5);
x.setArmed(false, "harness: disarmed mid-run"); // the board disarmed under us
idleStatus();
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const present = new Set();
for (const m of markup.matchAll(/id="([A-Za-z][A-Za-z0-9]*)"/g)) present.add(m[1]);
check(present.size > 15, `the markup's ids were parsed (${present.size})`);
const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "",
"every element the script reached for exists in the page");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
})();
+367
View File
@@ -0,0 +1,367 @@
// tests for the control protocol: tests/test_proto.cpp
#include <cstdio>
#include <cstring>
#include <string>
#include "keystate.h"
#include "proto.h"
#include "test.h"
using kr::proto::Cmd;
using kr::proto::Message;
namespace {
Cmd parse(const char *s) {
static Message m;
kr::proto::initMessage(m);
const Cmd c = kr::proto::parse(s, strlen(s), m);
return c;
}
Cmd parse(const char *s, Message &m) { return kr::proto::parse(s, strlen(s), m); }
std::string readFile(const char *path) {
std::string out;
FILE *f = fopen(path, "rb");
if (!f) return out;
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
fclose(f);
return out;
}
} // namespace
int main() {
CASE("single-character commands");
{
CHECK(parse("H") == Cmd::Hello);
CHECK(parse("K") == Cmd::KeepAlive);
CHECK(parse("R") == Cmd::ReleaseAll);
CHECK(parse("C") == Cmd::SecureAttention);
CHECK(parse("A1") == Cmd::Arm);
CHECK(parse("A0") == Cmd::Disarm);
// hand-typed clients end lines with \n: tolerated
CHECK(parse("A1\n") == Cmd::Arm);
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
}
CASE("garbage is rejected, never half-accepted");
{
CHECK(parse("") == Cmd::Bad);
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
CHECK(parse("A2") == Cmd::Bad);
CHECK(parse("A11") == Cmd::Bad);
CHECK(parse("HH") == Cmd::Bad);
CHECK(parse("Z") == Cmd::Bad);
CHECK(parse("hello") == Cmd::Bad);
CHECK(parse("S") == Cmd::Bad);
CHECK(parse("S=") == Cmd::Bad);
}
CASE("state message: modifiers and usages");
{
Message m;
CHECK(parse("S=00:", m) == Cmd::State);
CHECK_EQ(m.nUsages, 0); // "nothing pressed" is a legal state
CHECK_EQ(m.mods, 0);
CHECK(parse("S=04:28", m) == Cmd::State); // LeftAlt + Enter
CHECK_EQ(m.mods, 0x04);
CHECK_EQ(m.nUsages, 1);
CHECK_EQ(m.usages[0], 0x28);
CHECK(parse("S=0a:04,05,06", m) == Cmd::State);
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.nUsages, 3);
CHECK_EQ(m.usages[2], 0x06);
CHECK(parse("S=0A:AB,cd", m) == Cmd::State); // hex case is free
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.usages[0], 0xAB);
CHECK_EQ(m.usages[1], 0xCD);
}
CASE("state message: strict field syntax");
{
CHECK(parse("S=00:04,") == Cmd::Bad); // trailing comma
CHECK(parse("S=00:4") == Cmd::Bad); // usage must be two hex digits
CHECK(parse("S=00:04gg") == Cmd::Bad); // not hex
CHECK(parse("S=0:0400") == Cmd::Bad); // modifier byte must be two digits
CHECK(parse("S=00:00") == Cmd::Bad); // 0x00 is not a key
CHECK(parse("S=00 :04") == Cmd::Bad); // no spaces anywhere
CHECK(parse("S=00") == Cmd::Bad); // the colon is not optional
CHECK(parse("S =00:") == Cmd::Bad);
}
CASE("state message: bounds");
{
// exactly the cap is fine, one over is not
char big[128];
for (int n = 1; n <= static_cast<int>(kr::kMaxKeysPerMessage) + 2; n++) {
std::strcpy(big, "S=00:");
size_t o = std::strlen(big);
for (int i = 0; i < n; i++) {
big[o++] = '0';
big[o++] = static_cast<char>('4' + (i % 6));
big[o++] = ',';
}
big[o - 1] = '\0';
const bool withinCap = n <= static_cast<int>(kr::kMaxKeysPerMessage);
CHECK(parse(big) == (withinCap ? Cmd::State : Cmd::Bad));
}
}
CASE("chord message must press something");
{
Message m;
CHECK(parse("Y=05:4c", m) == Cmd::Chord); // Ctrl+Alt+Del as a chord
CHECK_EQ(m.mods, 0x05);
CHECK_EQ(m.usages[0], 0x4C);
// A lone Super/Win press is a chord of nothing but a modifier ...
CHECK(parse("Y=08:", m) == Cmd::Chord);
CHECK_EQ(m.mods, 0x08);
CHECK_EQ(m.nUsages, 0);
// ... but a chord that presses neither a modifier nor a key is not a chord.
CHECK(parse("Y=00:") == Cmd::Bad);
CHECK(parse("Y=05") == Cmd::Bad); // the colon is not optional
}
CASE("auth message: token charset and bounds");
{
Message m;
CHECK(parse("Thunter2", m) == Cmd::Auth);
CHECK_STR(m.text, "hunter2");
CHECK(parse("Tabc._-:9", m) == Cmd::Auth);
CHECK_STR(m.text, "abc._-:9");
CHECK(parse("T") == Cmd::Bad); // empty token
CHECK(parse("Tbad token") == Cmd::Bad); // spaces and control chars are out
CHECK(parse("Tbad\tx") == Cmd::Bad);
char over[80];
std::memset(over, 'a', sizeof(over));
over[0] = 'T';
over[kr::kTokenMax + 2] = '\0'; // "T" + 33 chars = one too long
CHECK_EQ(std::strlen(over), kr::kTokenMax + 2u); // "T" + 33 token chars
CHECK(parse(over) == Cmd::Bad);
char fits[80];
std::memset(fits, 'a', sizeof(fits));
fits[0] = 'T';
fits[kr::kTokenMax + 1] = '\0'; // token exactly at the limit
CHECK(parse(fits) == Cmd::Auth);
}
CASE("state renders back to the same wire format");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x28};
CHECK_EQ(kr::proto::formatState(buf, sizeof(buf), 0x0A, keys, 2), 10u);
CHECK_STR(buf, "S=0a:04,28");
// "nothing pressed" needs the colon, and needs a buffer that fits the NUL
CHECK_EQ(kr::proto::formatState(buf, 5, 0, nullptr, 0), 0u);
CHECK_EQ(kr::proto::formatState(buf, 6, 0, nullptr, 0), 5u);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatState(buf, sizeof(buf), 0, nullptr, 0) > 0);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatChord(buf, sizeof(buf), 0x05, keys, 1) > 0);
CHECK_STR(buf, "Y=05:04");
// a too-small buffer reports failure instead of writing past the end
char tiny[6] = {0};
CHECK_EQ(kr::proto::formatState(tiny, sizeof(tiny), 0x0A, keys, 2), 0u);
}
CASE("round trip: render then parse yields the same state");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x05, 0x28, 0x4C};
kr::proto::formatState(buf, sizeof(buf), 0x5A, keys, 4);
Message m;
CHECK(parse(buf, m) == Cmd::State);
CHECK_EQ(m.mods, 0x5A);
CHECK_EQ(m.nUsages, 4);
for (int i = 0; i < 4; i++) CHECK_EQ(m.usages[i], keys[i]);
}
CASE("error frames carry their code");
{
char buf[32];
CHECK(kr::proto::formatError(buf, sizeof(buf), 4, "arm required") > 0);
CHECK_STR(buf, "E4:arm required");
CHECK_EQ(kr::proto::formatError(buf, 4, 4, "too long for this buffer"), 0u);
}
CASE("text message: pace, hex, and the bounds that keep one frame one command");
{
Message m;
CHECK(parse("P=0:6869", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 0); // 0 = the board's own default pace
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
CHECK(parse("P=250:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 250);
CHECK(parse("P=1:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 1);
// Strict, like every other command: the digits, the colon and a whole number
// of bytes are all required, and a rejected frame types nothing at all.
CHECK(parse("P") == Cmd::Bad);
CHECK(parse("P=") == Cmd::Bad);
CHECK(parse("P=:68") == Cmd::Bad); // the pace is not optional
CHECK(parse("P=12") == Cmd::Bad); // no colon, no text
CHECK(parse("P=12:") == Cmd::Bad); // empty text is not a paste
CHECK(parse("P=12:6") == Cmd::Bad); // half a byte
CHECK(parse("P=12:686") == Cmd::Bad);
CHECK(parse("P=12:zz") == Cmd::Bad);
CHECK(parse("P=12:68 69") == Cmd::Bad); // no spaces
CHECK(parse("P=251:68") == Cmd::Bad); // slower than anything sane
CHECK(parse("P=99999:68") == Cmd::Bad); // and no overflow on the digits
CHECK(parse("P=12:6869extra") == Cmd::Bad);
CHECK(parse("PX:68") == Cmd::Bad);
// One frame is one command: kTextFrameMax characters must fit inside one 256-byte
// frame, and one more must be refused rather than buffered or truncated.
std::string body(2 * kr::kTextFrameMax, '0');
for (size_t i = 0; i < body.size(); i++) body[i] = (i & 1) ? '1' : '6'; // all 'a'
const std::string big = "P=12:" + body;
const std::string more = big + "61";
CHECK_EQ(big.size(), 5u + 2u * kr::kTextFrameMax);
CHECK(big.size() <= kr::kWsMessageCap);
CHECK(kr::proto::parse(big.c_str(), big.size(), m) == Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
CHECK_EQ(m.payload[0], 'a');
CHECK(more.size() <= kr::kWsMessageCap); // refused on count, not on length
CHECK(kr::proto::parse(more.c_str(), more.size(), m) == Cmd::Bad);
// A trailing newline is tolerated, as it is for every other command.
CHECK(parse("P=0:6869\n") == Cmd::Text);
}
CASE("constant-time token comparison");
{
CHECK(kr::proto::constTimeEq("hunter2", 7, "hunter2", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter3", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter", 6));
CHECK(!kr::proto::constTimeEq("", 0, "x", 1));
CHECK(kr::proto::constTimeEq("", 0, "", 0));
CHECK(!kr::proto::constTimeEq("hunter22", 8, "hunter2", 7)); // prefix is not a match
}
CASE("every button on the controller page is a chord the firmware accepts");
{
// The page's special keys carry their chord in the wire format itself, as
// data-chord="<mods>:<usages>". That is what keeps the page free of chord logic --
// and what makes a typo in one of them a button that either does nothing or, worse,
// sends a different key than the label on it promises. Nothing on the browser side
// would ever notice, so every one goes through the real parser here.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
int buttons = 0, accepted = 0;
const std::string needle = "data-chord=\"";
size_t pos = 0;
while ((pos = html.find(needle, pos)) != std::string::npos) {
const size_t begin = pos + needle.size();
const size_t end = html.find('"', begin);
if (end == std::string::npos) break;
const std::string wire = "Y=" + html.substr(begin, end - begin);
Message m;
kr::proto::initMessage(m);
if (kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord &&
(m.mods != 0 || m.nUsages > 0)) {
accepted++;
}
buttons++;
pos = end;
}
CHECK(buttons >= 8); // a broken extraction must not read as a pass
CHECK_EQ(buttons, accepted);
// The window-management keys the README documents. Asserted by their bytes so a
// silent rename cannot leave the documentation describing a button that is gone.
const std::string gui = std::string("08:");
CHECK_EQ((int)kr::kModLgui, 0x08); // the page's "08" must mean what this says
CHECK(html.find("data-chord=\"" + gui + "50\"") != std::string::npos); // Super+Left
CHECK(html.find("data-chord=\"" + gui + "4f\"") != std::string::npos); // Super+Right
CHECK(html.find("data-chord=\"" + gui + "4b\"") != std::string::npos); // Super+PageUp
}
CASE("every chord button sends the combination printed on it");
{
// The test above proves each chord PARSES, which is not the same claim: an
// Alt+Tab button whose bytes were Ctrl+Alt+A parses perfectly, is armed-gated,
// arrives intact, and does something else entirely. Nothing in the browser would
// ever notice. So each labelled button is pinned to the combination its label
// promises, by modifier byte and usage id.
struct Expected { const char *label; const char *wire; uint8_t mods; uint8_t usage; };
const Expected expected[] = {
{"Ctrl+Alt+F1", "05:3a", kr::kModLctrl | kr::kModLalt, 0x3a},
{"Ctrl+Alt+F2", "05:3b", kr::kModLctrl | kr::kModLalt, 0x3b},
{"Ctrl+Alt+F3", "05:3c", kr::kModLctrl | kr::kModLalt, 0x3c},
{"Ctrl+Alt+F4", "05:3d", kr::kModLctrl | kr::kModLalt, 0x3d},
{"Alt+Tab", "04:2b", kr::kModLalt, kr::kUsageTab},
{"Win", "08:", kr::kModLgui, 0x00},
{"PrtSc", "00:46", 0x00, 0x46},
{"Menu", "00:65", 0x00, 0x65},
{"Super+Left", "08:50", kr::kModLgui, 0x50},
{"Super+Right", "08:4f", kr::kModLgui, 0x4f},
{"Super+PageUp", "08:4b", kr::kModLgui, 0x4b},
};
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
for (const auto &e : expected) {
const std::string attr = std::string("data-chord=\"") + e.wire + "\"";
CHECK(html.find(attr) != std::string::npos);
const std::string wire = std::string("Y=") + e.wire;
Message m;
CHECK(kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord);
if (m.mods != e.mods || m.usages[0] != e.usage ||
(e.usage != 0 && m.nUsages != 1)) {
std::printf(" %s is on the wire as %s, which is not what it says\n",
e.label, wire.c_str());
}
CHECK_EQ(m.mods, e.mods);
CHECK_EQ(e.usage == 0 ? 0 : m.usages[0], e.usage);
CHECK(e.usage == 0 ? m.nUsages == 0 : m.nUsages == 1);
}
// Alt+Tab specifically, because it was wrong once: LAlt alone with Tab, and no
// Ctrl anywhere near it -- Ctrl+Alt+something is a VT switch or a panic key.
CHECK(kr::kModLctrl == 0x01 && kr::kModLalt == 0x04);
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
}
CASE("the page's text constants are the board's text constants");
{
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
// using numbers it has to keep in step with config.h by hand. If they drift, the
// board answers E8 in the middle of somebody's paste.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[64];
snprintf(needle, sizeof(needle), "const TEXT_FRAME_MAX = %u;",
(unsigned)kr::kTextFrameMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const TEXT_JOB_MAX = %u;", (unsigned)kr::kTextJobCap);
CHECK(html.find(needle) != std::string::npos);
// And the page must actually use the P= command the firmware parses.
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
}
return report("proto");
}
+248
View File
@@ -0,0 +1,248 @@
#!/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):
found = re.search(rf"\b{name}\s*=\s*(\d+)\s*;", config_src)
return int(found.group(1)) 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("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'"):
check(command in proto_src, f"proto.h parses {command}")
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
"proto.h still requires a chord to press something")
print(f"relayctl {checks} checks, {failures} failures")
sys.exit(1 if failures else 0)
+386
View File
@@ -0,0 +1,386 @@
// tests for pasted text: tests/test_text.cpp
//
// The character map is the part worth being pedantic about: every one of the 95
// printable ASCII characters has to map to a real keystroke, because a paste that
// silently loses or changes one character is worse than a paste that refuses. So
// the whole range is walked, not sampled.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "keystate.h"
#include "proto.h"
#include "test.h"
#include "text.h"
using kr::KeyboardReport;
using kr::KeyboardState;
using kr::text::Result;
using kr::text::Stroke;
using kr::text::Typewriter;
namespace {
int countHeld(const KeyboardReport &r) {
int n = 0;
for (size_t i = 0; i < kr::kReportSlots; i++) {
if (r.keys[i] != kr::kUsageNone) n++;
}
return n;
}
} // namespace
int main() {
CASE("every printable ASCII character maps to a keystroke");
{
for (int ch = 0x20; ch <= 0x7E; ch++) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(s.typable);
CHECK(s.usage != kr::kUsageNone); // typable implies a real usage id
}
}
CASE("shifted glyphs ask for LShift, unshifted ones do not");
{
// Letters: position, plus Shift for the uppercase -- never a different key.
const Stroke a = kr::text::strokeFor('a'), A = kr::text::strokeFor('A');
CHECK(a.typable && A.typable);
CHECK_EQ(a.usage, 0x04);
CHECK_EQ(A.usage, 0x04); // same physical key
CHECK_EQ(a.mods, 0);
CHECK_EQ(A.mods, kr::kModLshift);
CHECK_EQ(kr::text::strokeFor('z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('Z').usage, 0x1D);
CHECK_EQ(kr::text::strokeFor('M').usage, 0x10);
// Digits: 1..9 run 0x1E..0x26 and 0 is the one that breaks the arithmetic.
CHECK_EQ(kr::text::strokeFor('1').usage, 0x1E);
CHECK_EQ(kr::text::strokeFor('9').usage, 0x26);
CHECK_EQ(kr::text::strokeFor('0').usage, 0x27);
CHECK_EQ(kr::text::strokeFor('0').mods, 0);
// The shifted number row, which is where a shifted-vs-unshifted slip shows up.
const int shiftPairs[][2] = {
{'!', 0x1E}, {'@', 0x1F}, {'#', 0x20}, {'$', 0x21}, {'%', 0x22},
{'^', 0x23}, {'&', 0x24}, {'*', 0x25}, {'(', 0x26}, {')', 0x27},
{'_', 0x2D}, {'+', 0x2E}, {'{', 0x2F}, {'}', 0x30}, {'|', 0x31},
{':', 0x33}, {'"', 0x34}, {'~', 0x35}, {'<', 0x36}, {'>', 0x37},
{'?', 0x38},
};
for (const auto &p : shiftPairs) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(p[0]));
CHECK(s.typable);
CHECK_EQ(s.usage, p[1]);
CHECK_EQ(s.mods, kr::kModLshift);
}
// And their unshifted partners on the same keys.
CHECK_EQ(kr::text::strokeFor('-').usage, 0x2D);
CHECK_EQ(kr::text::strokeFor('-').mods, 0);
CHECK_EQ(kr::text::strokeFor('=').usage, 0x2E);
CHECK_EQ(kr::text::strokeFor('[').usage, 0x2F);
CHECK_EQ(kr::text::strokeFor(']').usage, 0x30);
CHECK_EQ(kr::text::strokeFor('\\').usage, 0x31);
CHECK_EQ(kr::text::strokeFor(';').usage, 0x33);
CHECK_EQ(kr::text::strokeFor('\'').usage, 0x34);
CHECK_EQ(kr::text::strokeFor('`').usage, 0x35);
CHECK_EQ(kr::text::strokeFor(',').usage, 0x36);
CHECK_EQ(kr::text::strokeFor('.').usage, 0x37);
CHECK_EQ(kr::text::strokeFor('/').usage, 0x38);
}
CASE("whitespace");
{
CHECK_EQ(kr::text::strokeFor(' ').usage, kr::kUsageSpace);
CHECK_EQ(kr::text::strokeFor('\t').usage, kr::kUsageTab);
CHECK_EQ(kr::text::strokeFor('\n').usage, kr::kUsageEnter);
// \r types Enter. Normalising CRLF to LF is the controller's job: doing it here
// would need a look-ahead, and a chunk boundary can split the pair.
CHECK_EQ(kr::text::strokeFor('\r').usage, kr::kUsageEnter);
}
CASE("characters this map cannot express are refused, not guessed at");
{
const int refused[] = {0x00, 0x01, 0x07, 0x08, 0x0B, 0x0C, 0x1B, 0x1F, 0x7F,
0x80, 0xA0, 0xC3, 0xE9, 0xFF}; // e.g. UTF-8 'é'
for (int ch : refused) {
const Stroke s = kr::text::strokeFor(static_cast<uint8_t>(ch));
CHECK(!s.typable);
CHECK_EQ(s.usage, 0);
CHECK_EQ(s.mods, 0);
}
}
CASE("feed: all of a frame or none of it");
{
Typewriter t;
const uint8_t ok[] = {'h', 'i'};
CHECK(kr::text::strokeFor('h').typable);
CHECK(t.feed(ok, 2).result == Result::Ok);
CHECK_EQ(t.pending(), 2);
const uint8_t mixed[] = {'o', '!', 0xC3, 'x'};
const auto bad = t.feed(mixed, 4);
CHECK(bad.result == Result::Untypeable);
CHECK_EQ(bad.index, 2); // offset within THIS frame
CHECK_EQ(bad.byte, 0xC3);
CHECK_EQ(t.pending(), 2); // nothing partial was appended
// The cap is on characters still to type, and a full buffer is reported as such
// rather than truncating a paste.
Typewriter big;
uint8_t run[kr::kTextJobCap];
memset(run, 'a', sizeof(run));
CHECK(big.feed(run, kr::kTextJobCap).result == Result::Ok);
CHECK(big.feed(run, 1).result == Result::Full);
CHECK_EQ(big.pending(), kr::kTextJobCap);
CHECK(Typewriter().feed(nullptr, 0).result == Result::Ok);
}
CASE("pace: 0 means the board's default, and a fast pace still separates edges");
{
Typewriter t;
const uint8_t one[] = {'q'};
t.feed(one, 1);
t.start(1000, 0);
CHECK_EQ(t.periodMs(), kr::kTextPeriodMsDefault);
CHECK(t.holdMs() >= 1);
CHECK(t.holdMs() <= kr::kTextHoldMaxMs);
CHECK_EQ(t.holdMs() + t.gapMs(), t.periodMs()); // one press and one release per char
t.cancel();
t.feed(one, 1);
t.start(0, 9999); // absurdly slow is clamped, not obeyed
CHECK_EQ(t.periodMs(), kr::kTextPeriodMaxMs);
CHECK_EQ(t.holdMs(), kr::kTextHoldMaxMs);
t.cancel();
t.feed(one, 1);
t.start(0, 1); // as fast as it will go
CHECK_EQ(t.holdMs(), 1);
CHECK_EQ(t.gapMs(), 0);
}
CASE("step: the whole report sequence for two characters");
{
Typewriter t;
const uint8_t txt[] = {'h', 'i'};
t.feed(txt, 2);
t.start(1000, 10); // hold 5, gap 5
CHECK_EQ(t.holdMs(), 5);
CHECK_EQ(t.gapMs(), 5);
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(!t.step(999, baseline, r, pressed)); // not due yet
CHECK(t.step(1000, baseline, r, pressed)); // press 'h'
CHECK(pressed);
CHECK_EQ(r.modifiers, 0);
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('h').usage);
CHECK(!t.step(1004, baseline, r, pressed)); // still holding
CHECK(t.step(1005, baseline, r, pressed)); // release
CHECK(!pressed);
CHECK_EQ(countHeld(r), 0);
CHECK(t.step(1010, baseline, r, pressed)); // press 'i'
CHECK(pressed);
CHECK_EQ(r.keys[0], kr::text::strokeFor('i').usage);
CHECK(t.step(1015, baseline, r, pressed)); // release, and the run is over
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(2000, baseline, r, pressed)); // and nothing is left to emit
}
CASE("a paste is typed on top of what the operator holds, and back to it");
{
Typewriter t;
const uint8_t txt[] = {'A'}; // uppercase: this one needs Shift
t.feed(txt, 1);
t.start(0, 20);
// The operator is holding Ctrl. A pasted 'A' is therefore Ctrl+Shift+a.
KeyboardState baseline;
const uint8_t held[] = {kr::text::strokeFor('s').usage};
baseline.apply(kr::kModLctrl, held, 1);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed));
CHECK_EQ(r.modifiers, kr::kModLctrl | kr::kModLshift);
CHECK_EQ(countHeld(r), 2);
CHECK(baseline.isDown(kr::text::strokeFor('s').usage));
CHECK(t.step(10, baseline, r, pressed)); // release returns to the baseline
CHECK_EQ(r.modifiers, kr::kModLctrl); // Ctrl still down
CHECK_EQ(countHeld(r), 1);
CHECK_EQ(r.keys[0], kr::text::strokeFor('s').usage); // not released by the paste
}
CASE("more than six keys together is reported as a real keyboard reports it");
{
Typewriter t;
const uint8_t txt[] = {'x'};
t.feed(txt, 1);
t.start(0, 20);
KeyboardState six;
const uint8_t keys6[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09};
six.apply(0, keys6, 6);
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, six, r, pressed)); // a seventh key is impossible:
for (size_t i = 0; i < kr::kReportSlots; i++) CHECK_EQ(r.keys[i], kr::kUsageErrorRollover);
KeyboardState rolled;
const uint8_t keys7[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A};
rolled.apply(0, keys7, 7);
CHECK(rolled.rollover());
Typewriter t2;
t2.feed(txt, 1);
t2.start(0, 20);
CHECK(t2.step(0, rolled, r, pressed));
CHECK_EQ(r.keys[0], kr::kUsageErrorRollover); // rollover propagates, never hides
}
CASE("cancel drops the rest of the run");
{
Typewriter t;
const uint8_t txt[] = {'o', 'n', 'e', ' ', 't', 'w', 'o'};
t.feed(txt, sizeof(txt));
t.start(0, 10);
CHECK(t.active());
KeyboardState baseline;
KeyboardReport r;
bool pressed = false;
CHECK(t.step(0, baseline, r, pressed)); // one character pressed
t.cancel(); // the panic path
CHECK(!t.active());
CHECK_EQ(t.pending(), 0);
CHECK(!t.step(1000, baseline, r, pressed)); // nothing continues afterwards
CHECK_EQ(t.feed(txt, sizeof(txt)).result, Result::Ok);
CHECK_EQ(t.pending(), sizeof(txt)); // and it is reusable
}
CASE("a run already in progress keeps the pace it started with");
{
Typewriter t;
const uint8_t txt[] = {'a'};
t.feed(txt, 1);
t.start(0, 10);
CHECK_EQ(t.periodMs(), 10);
t.feed(txt, 1);
t.start(0, 200); // a later frame must not reprieve
CHECK_EQ(t.periodMs(), 10); // or race the text already visible
t.cancel();
t.feed(txt, 1);
t.start(0, 200);
CHECK_EQ(t.periodMs(), 200); // a fresh run does take the new pace
}
CASE("text renders to the wire format and parses back");
{
char buf[512];
const uint8_t txt[] = {'h', 'i'};
const size_t n = kr::proto::formatText(buf, sizeof(buf), 0, txt, 2);
CHECK_EQ(n, 8u); // "P=0:6869"
CHECK_STR(buf, "P=0:6869");
kr::proto::Message m;
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 0);
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
// The pace travels too, in decimal, and the whole frame is still ASCII.
kr::proto::formatText(buf, sizeof(buf), 250, txt, 2);
CHECK_STR(buf, "P=250:6869");
CHECK(kr::proto::parse(buf, strlen(buf), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.periodMs, 250);
// The largest legal frame must fit in one WebSocket message: 256 bytes, or the
// board rejects the frame before the parser ever sees it.
uint8_t max[kr::kTextFrameMax];
memset(max, 'a', sizeof(max));
const size_t mlen = kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs,
max, kr::kTextFrameMax);
CHECK(mlen > 0);
CHECK(mlen <= kr::kWsMessageCap);
CHECK(kr::proto::parse(buf, mlen, m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
// A buffer that cannot hold the whole frame gets nothing, not a truncated paste.
char small[8] = {0};
CHECK_EQ(kr::proto::formatText(small, sizeof(small), 0, max, 20), 0u);
CHECK_STR(small, "");
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), 0, max, 0), 0u); // empty is not a paste
CHECK_EQ(kr::proto::formatText(buf, sizeof(buf), kr::kTextPeriodMaxMs + 1, txt, 2), 0u);
}
CASE("a frame the controller page sends becomes the reports the target sees");
{
// The browser builds "P=<ms>:<hex>"; the board parses it, expands it and emits
// press/release pairs. This is the whole path in one place, with no browser and no
// USB in it -- which is exactly why it can be asserted here rather than guessed at.
const char *text = "Hi there";
std::string frame = "P=12:";
for (const char *p = text; *p; p++) {
char hex[3];
snprintf(hex, sizeof(hex), "%02x", (unsigned char)*p);
frame += hex;
}
kr::proto::Message m;
CHECK(kr::proto::parse(frame.c_str(), frame.size(), m) == kr::proto::Cmd::Text);
CHECK_EQ(m.nPayload, strlen(text));
CHECK_EQ(m.periodMs, 12);
Typewriter t;
CHECK(t.feed(m.payload, m.nPayload).result == Result::Ok);
t.start(0, m.periodMs);
KeyboardState none; // nothing held by the operator
std::vector<KeyboardReport> out;
uint32_t clockMs = 0;
// Ten seconds of loop passes at 1 ms: long enough to type it all out.
for (int tick = 0; tick < 10000; tick++, clockMs += 1) {
KeyboardReport r;
bool pressed = false;
if (!t.step(clockMs, none, r, pressed)) continue;
out.push_back(r);
}
const size_t len = strlen(text);
CHECK_EQ(out.size(), len * 2); // one press and one release per character
CHECK(!t.active()); // and it finished on its own
for (size_t i = 0; i < len; i++) {
const Stroke want = kr::text::strokeFor((uint8_t)text[i]);
const KeyboardReport &press = out[i * 2];
const KeyboardReport &release = out[i * 2 + 1];
CHECK_EQ(press.modifiers, want.mods);
CHECK_EQ(press.keys[0], want.usage);
CHECK_EQ(countHeld(press), 1);
CHECK_EQ(release.modifiers, 0); // nothing left down between characters
CHECK_EQ(countHeld(release), 0);
}
// 'H' is the shifted glyph and 'i' is not: the two reports differ in Shift and
// nothing else. Usages are positions, so H is 0x0B and i is 0x0C.
CHECK_EQ(out[0].modifiers, kr::kModLshift);
CHECK_EQ(out[0].keys[0], 0x0b);
CHECK_EQ(out[2].modifiers, 0);
CHECK_EQ(out[2].keys[0], 0x0c);
// The space in "Hi there" is index 2, and a key -- not an absence of one.
CHECK_EQ(out[4].keys[0], kr::kUsageSpace);
CHECK_EQ(out[6].keys[0], 0x17); // 't'
}
return report("text");
}
+424
View File
@@ -0,0 +1,424 @@
// tests for SHA-1 / base64 / the WebSocket codec / the handshake parser
#include <cstdlib>
#include <cstring>
#include "ws_codec.h"
#include "test.h" // CHECK / CHECK_EQ / CHECK_STR / report()
using namespace kr;
namespace {
void hexDigest(const uint8_t d[20], char out[41]) {
static const char kHex[] = "0123456789abcdef";
for (int i = 0; i < 20; i++) {
out[2 * i] = kHex[d[i] >> 4];
out[2 * i + 1] = kHex[d[i] & 0xF];
}
out[40] = '\0';
}
// Build a client frame the way a browser does: masked payload, mask key appended
// in front of it. Returns the frame length.
size_t buildClientFrame(uint8_t *out, size_t cap, uint8_t opcode, const char *payload,
size_t len, bool mask = true, bool fin = true, uint8_t rsv = 0,
uint64_t declaredLen = 0xFFFFFFFFull) {
if (declaredLen == 0xFFFFFFFFull) declaredLen = len;
// A test that asks for a frame larger than its own buffer is a broken test, not
// a stack smash: say so and stop.
if (2 + 8 + 4 + len > cap) {
std::fprintf(stderr, "buildClientFrame: %zu bytes needed, buffer is %zu\n", 2 + 8 + 4 + len,
cap);
std::abort();
}
size_t o = 0;
out[o++] = static_cast<uint8_t>((fin ? 0x80 : 0x00) | (rsv << 4) | (opcode & 0x0F));
const uint8_t m = mask ? 0x80 : 0x00;
if (declaredLen < 126) {
out[o++] = static_cast<uint8_t>(m | declaredLen);
} else if (declaredLen <= 0xFFFF) {
out[o++] = m | 126;
out[o++] = static_cast<uint8_t>(declaredLen >> 8);
out[o++] = static_cast<uint8_t>(declaredLen);
} else {
out[o++] = m | 127;
for (int i = 0; i < 8; i++) out[o++] = static_cast<uint8_t>(declaredLen >> (56 - 8 * i));
}
uint8_t maskKey[4] = {0xDE, 0xAD, 0xBE, 0xEF};
if (mask) {
memcpy(out + o, maskKey, 4);
o += 4;
}
for (size_t i = 0; i < len; i++) {
out[o++] = mask ? static_cast<uint8_t>(payload[i] ^ maskKey[i & 3])
: static_cast<uint8_t>(payload[i]);
}
return o;
}
} // namespace
int main() {
char hex[64];
CASE("SHA-1 against the published vectors");
{
uint8_t d[20];
sha1("", 0, d);
hexDigest(d, hex);
CHECK_STR(hex, "da39a3ee5e6b4b0d3255bfef95601890afd80709");
sha1("abc", 3, d);
hexDigest(d, hex);
CHECK_STR(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
// 56 bytes is the padding boundary: with 0x80 + the 8-byte length the block
// overflows, so the count lands in a second block. Classic SHA-1 bug, and the
// reason this vector is here (digest cross-checked against Python hashlib).
char fiftySix[57];
memset(fiftySix, 'a', 56);
fiftySix[56] = '\0';
sha1(fiftySix, 56, d);
hexDigest(d, hex);
CHECK_STR(hex, "c2db330f6083854c99d4b5bfb6e8f29f201be699");
// NIST's two-block message digest
sha1("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56, d);
hexDigest(d, hex);
CHECK_STR(hex, "84983e441c3bd26ebaae4aa1f95129e5e54670f1");
// the canonical million-character vector: exercises many blocks
{
Sha1 many;
char chunk[64];
memset(chunk, 'a', sizeof(chunk));
for (int i = 0; i < 1000000 / 64; i++) many.update(chunk, sizeof(chunk));
many.finish(d);
hexDigest(d, hex);
CHECK_STR(hex, "34aa973cd4c4daa4f61eeb2bdbad27316534016f");
}
}
CASE("SHA-1 incremental equals one-shot");
{
const char *text = "the quick brown fox jumps over the lazy dog, twice over";
uint8_t one[20], many[20];
sha1(text, strlen(text), one);
Sha1 s;
for (size_t i = 0; i < strlen(text); i++) s.update(text + i, 1);
s.finish(many);
CHECK(memcmp(one, many, 20) == 0);
// reuse after finish() must start clean
uint8_t again[20];
s.update("abc", 3);
s.finish(again);
sha1("abc", 3, one);
CHECK(memcmp(again, one, 20) == 0);
}
CASE("base64 against RFC 4648");
{
char out[32];
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("\0"), 0, out, sizeof(out)), 0u);
CHECK_STR(out, "");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("f"), 1, out, sizeof(out)), 4u);
CHECK_STR(out, "Zg==");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("fo"), 2, out, sizeof(out)), 4u);
CHECK_STR(out, "Zm8=");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foo"), 3, out, sizeof(out)), 4u);
CHECK_STR(out, "Zm9v");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foob"), 4, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYg==");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("fooba"), 5, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYmE=");
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foobar"), 6, out, sizeof(out)), 8u);
CHECK_STR(out, "Zm9vYmFy");
// one byte short of the requirement must refuse, not overwrite
char tiny[4] = {'x', 'x', 'x', '\0'};
CHECK_EQ(base64Encode(reinterpret_cast<const uint8_t *>("foo"), 3, tiny, 4), 0u);
CHECK_STR(tiny, "xxx");
}
CASE("the RFC 6455 handshake example");
{
const char *key = "dGhlIHNhbXBsZSBub25jZQ==";
char accept[64] = "";
CHECK(ws::acceptFor(key, strlen(key), accept, sizeof(accept)));
CHECK_STR(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
// a different key must produce a different accept, and short buffers refuse
char other[64] = "";
CHECK(ws::acceptFor("anotherKey12345=", strlen("anotherKey12345="), other, sizeof(other)));
CHECK(std::strcmp(accept, other) != 0);
CHECK(!ws::acceptFor("", 0, accept, sizeof(accept)));
CHECK(!ws::acceptFor(key, strlen(key), accept, 8));
}
CASE("outbound frame encoding");
{
uint8_t buf[64];
size_t n = ws::encodeText("A1", 2, buf, sizeof(buf));
CHECK_EQ(n, 4u);
CHECK_EQ(buf[0], 0x81); // FIN + text
CHECK_EQ(buf[1], 2); // unmasked, length 2
CHECK_EQ(buf[2], 'A');
CHECK_EQ(buf[3], '1');
n = ws::encodeText("", 0, buf, sizeof(buf));
CHECK_EQ(n, 2u);
CHECK_EQ(buf[1], 0);
// 125 bytes is the last length that fits the 7-bit field, 126 the first that
// needs the 16-bit extension
char blob[200];
memset(blob, 'x', sizeof(blob));
uint8_t big[400];
n = ws::encodeText(blob, 125, big, sizeof(big));
CHECK_EQ(n, 127u);
CHECK_EQ(big[1], 125);
CHECK_EQ(big[2], 'x');
n = ws::encodeText(blob, 126, big, sizeof(big));
CHECK_EQ(n, 130u);
CHECK_EQ(big[1], 126);
CHECK_EQ(big[2], 0);
CHECK_EQ(big[3], 126);
CHECK_EQ(big[4], 'x');
// a buffer that is one byte too small refuses without writing the frame
uint8_t tight[10] = {0};
CHECK_EQ(ws::encodeText("0123456789ab", 12, tight, sizeof(tight)), 0u);
CHECK_EQ(tight[0], 0);
}
CASE("inbound frame decodes, byte by byte");
{
uint8_t wire[128], payloadBuf[64];
const size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "S=0a:04", 7);
CHECK_EQ(n, 13u); // 2 header + 4 mask + 7 payload
ws::Reader r(payloadBuf, sizeof(payloadBuf));
ws::In res = ws::In::NeedMore;
for (size_t i = 0; i < n; i++) {
res = r.feed(wire[i]);
if (i + 1 < n) CHECK(res == ws::In::NeedMore);
}
CHECK(res == ws::In::Text);
CHECK(r.finished());
CHECK_EQ(r.payloadLen(), 7u);
CHECK(memcmp(r.payload(), "S=0a:04", 7) == 0);
CHECK_EQ(r.error(), ws::Err::None);
// feeding more after completion keeps reporting the same frame until reset()
CHECK(r.feed(0x00) == ws::In::Text);
r.reset();
CHECK(!r.finished());
}
CASE("inbound frame decodes from a whole buffer");
{
uint8_t wire[256], payloadBuf[64];
const size_t a = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2);
const size_t b = buildClientFrame(wire + a, sizeof(wire) - a, ws::kOpText, "K", 1);
ws::Reader r(payloadBuf, sizeof(payloadBuf));
size_t consumed = 0;
CHECK(r.feedAll(wire, a + b, &consumed) == ws::In::Text);
CHECK_EQ(consumed, a);
CHECK(memcmp(r.payload(), "A1", 2) == 0);
r.reset();
CHECK(r.feedAll(wire + consumed, a + b - consumed, &consumed) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 1u);
CHECK_EQ(r.payload()[0], 'K');
}
CASE("empty frames and the 16-bit length field");
{
uint8_t wire[512];
char blob[130];
for (size_t i = 0; i < sizeof(blob); i++) blob[i] = static_cast<char>('a' + (i % 26));
uint8_t payloadBuf[200];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
const size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, nullptr, 0);
CHECK_EQ(n, 2u + 4u); // a masked frame sends its mask key anyway
CHECK(r.feedAll(wire, n) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 0u);
r.reset();
const size_t m = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 126);
CHECK_EQ(m, 4u + 4u + 126u); // 2 + 2 extended length, mask, payload
CHECK(r.feedAll(wire, m) == ws::In::Text);
CHECK_EQ(r.payloadLen(), 126u);
CHECK(memcmp(r.payload(), blob, 126) == 0);
// A 64-bit length field is parsed (8 extension bytes) and then refused, because
// the payload it announces cannot fit the caller's buffer. The decoder never
// buffers beyond the cap it was constructed with.
r.reset();
const size_t k = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 2, true, true,
0, 0x10000);
CHECK_EQ(k, 2u + 8u + 4u + 2u);
CHECK(r.feedAll(wire, k) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
}
CASE("control frames");
{
uint8_t wire[64], payloadBuf[64];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpPing, "hi", 2);
CHECK(r.feedAll(wire, n) == ws::In::Ping);
CHECK_EQ(r.payloadLen(), 2u);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpPong, nullptr, 0);
CHECK(r.feedAll(wire, n) == ws::In::Pong);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpClose, "\x03\xe8", 2);
CHECK(r.feedAll(wire, n) == ws::In::Close);
CHECK_EQ(r.payload()[0], 3);
CHECK_EQ(r.payload()[1], 232);
}
CASE("protocol deviations are hard errors, not graceful surprises");
{
uint8_t wire[600], payloadBuf[64];
ws::Reader r(payloadBuf, sizeof(payloadBuf));
// unmasked client frame: RFC 6455 5.1 says the server must fail the connection
size_t n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, /*mask=*/false);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::NotMasked);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpBinary, "A1", 2);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::BadOpcode);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, /*fin=*/false);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::Fragmented);
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, true, /*rsv=*/1);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::ReservedBits);
char blob[200];
for (size_t i = 0; i < sizeof(blob); i++) blob[i] = static_cast<char>('A' + (i % 26));
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpPing, blob, 126);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::ControlTooLong);
// a text frame larger than the reader's buffer is refused outright
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, blob, 123);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
// 64-bit length field declaring more than the buffer can hold
r.reset();
n = buildClientFrame(wire, sizeof(wire), ws::kOpText, "A1", 2, true, true, 0,
/*declaredLen=*/1u << 20);
CHECK(r.feedAll(wire, n) == ws::In::Error);
CHECK(r.error() == ws::Err::TooLong);
}
CASE("handshake request parsing");
{
const char *req =
"GET /ws?token=hunter2 HTTP/1.1\r\n"
"Host: keyrelay.local\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(rq.complete);
CHECK_STR(rq.method, "GET");
CHECK_STR(rq.path, "/ws");
CHECK_STR(rq.query, "token=hunter2");
CHECK(rq.upgradeWebsocket);
CHECK_STR(rq.wsKey, "dGhlIHNhbXBsZSBub25jZQ==");
// and the accept value for the key we just parsed is the RFC's own example
char accept[64] = "";
CHECK(ws::acceptFor(rq.wsKey, strlen(rq.wsKey), accept, sizeof(accept)));
CHECK_STR(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
}
CASE("handshake request parsing is case-insensitive and whitespace-tolerant");
{
const char *req =
"GET / HTTP/1.1\r\n"
"upGRADE: WebSocket\r\n"
"sec-WEBSOCKET-key: dGhlIHNhbXBsZSBub25jZQ== \r\n"
"\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(rq.upgradeWebsocket);
CHECK_STR(rq.wsKey, "dGhlIHNhbXBsZSBub25jZQ==");
}
CASE("plain HTTP request is not an upgrade");
{
const char *req = "GET /status?token=abc HTTP/1.0\r\nHost: x\r\n\r\n";
ws::Request rq;
CHECK_EQ(ws::parseRequest(req, strlen(req), rq), 1);
CHECK(!rq.upgradeWebsocket);
CHECK_STR(rq.path, "/status");
CHECK_EQ(rq.wsKey[0], 0);
char token[64] = "";
CHECK(ws::tokenFromQuery(rq.query, strlen(rq.query), token, sizeof(token)));
CHECK_STR(token, "abc");
}
CASE("incomplete or malformed requests are distinguishable");
{
ws::Request rq;
const char *partial = "GET / HTTP/1.1\r\nUpgrade: websocket\r\n";
CHECK_EQ(ws::parseRequest(partial, strlen(partial), rq), 0); // need more bytes
const char *noSpace = "GARBAGE\r\n\r\n";
CHECK_EQ(ws::parseRequest(noSpace, strlen(noSpace), rq), -1);
const char *noLineEnd = "GET /";
CHECK_EQ(ws::parseRequest(noLineEnd, strlen(noLineEnd), rq), 0);
}
CASE("query token extraction");
{
char token[64];
struct { const char *q; bool ok; const char *value; } cases[] = {
{"token=abc", true, "abc"},
{"a=1&token=abc", true, "abc"},
{"token=abc&b=2", true, "abc"},
{"a=1", false, ""},
{"token=", false, ""},
{"xtoken=abc", false, ""}, // must be a whole parameter
{"a=1xtoken=abc", false, ""},
{"", false, ""},
};
for (const auto &c : cases) {
token[0] = '\0';
const bool got = ws::tokenFromQuery(c.q, strlen(c.q), token, sizeof(token));
CHECK(got == c.ok);
if (c.ok) CHECK_STR(token, c.value);
}
// a token longer than the caller's buffer is refused, not truncated
char tiny[4] = "";
CHECK(!ws::tokenFromQuery("token=abcdefghij", strlen("token=abcdefghij"), tiny,
sizeof(tiny)));
}
return report("ws_codec");
}