Files
keyrelay/tests/test_proto.cpp
T
ducoterra e0780f58e0 phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)**
- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label).
- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no).
- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page.

**Tests / lint (100 % green)**
- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`.
- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2).
- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free.

**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓.

**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`.

**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit.

**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
2026-11-09 05:51:57 -05:00

662 lines
28 KiB
C++

// tests for the control protocol: tests/test_proto.cpp
#include <cstdio>
#include <cstring>
#include <string>
#include "keystate.h"
#include "proto.h"
#include "status.h"
#include "test.h"
using kr::proto::Cmd;
using kr::proto::Message;
using kr::StatusSnapshot; // the status frame, rendered by status.h
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("mouse message: the button byte restated, plus one report's movement");
{
Message m;
CHECK(parse("M=00:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0);
CHECK_EQ(m.dx, 0);
CHECK(parse("M=01:30:-4:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 1);
CHECK_EQ(m.dx, 30);
CHECK_EQ(m.dy, -4);
// Every field, at both ends of the range a signed byte carries.
CHECK(parse("M=1f:127:-127:127:-127", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x1f);
CHECK_EQ(m.dx, 127);
CHECK_EQ(m.dy, -127);
CHECK_EQ(m.wheel, 127);
CHECK_EQ(m.pan, -127);
CHECK(parse("M=15:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x15);
}
CASE("a mouse frame that is not exactly one report is refused");
{
Message m;
// Rejecting means nothing moves, which for a movement is harmless and for a button
// byte is the only safe answer. Guessing -- clamping, or splitting a big delta into
// several reports -- would make the board responsible for a number the controller
// got wrong.
CHECK(parse("M") == Cmd::Bad);
CHECK(parse("M=") == Cmd::Bad);
CHECK(parse("M=00") == Cmd::Bad);
CHECK(parse("M=00:") == Cmd::Bad);
CHECK(parse("M=00:0") == Cmd::Bad);
CHECK(parse("M=00:0:0:0") == Cmd::Bad); // four fields is not five
CHECK(parse("M=00:0:0:0:0:") == Cmd::Bad); // trailing delimiter
CHECK(parse("M=00:0:0:0:0:0") == Cmd::Bad); // ...nor a sixth one
CHECK(parse("M=0:0:0:0:0") == Cmd::Bad); // the button byte is two hex digits
CHECK(parse("M=20:0:0:0:0") == Cmd::Bad); // a sixth button has nowhere to go
CHECK(parse("M=ff:0:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:128:0:0:0") == Cmd::Bad); // more than one report's worth
CHECK(parse("M=00:-128:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:0:128:0:0") == Cmd::Bad);
CHECK(parse("M=00:+5:0:0:0") == Cmd::Bad); // no plus sign, whatever JS emits
CHECK(parse("M=00:5 :0:0:0") == Cmd::Bad); // no spaces
CHECK(parse("M=00:5x:0:0:0") == Cmd::Bad);
CHECK(parse("M=00::0:0:0") == Cmd::Bad); // an empty field is not a zero
CHECK(parse("m=00:0:0:0:0") == Cmd::Bad); // commands are uppercase
CHECK(parse("M=00:0:0:0:0 ") == Cmd::Bad); // nothing trails a command
CHECK(parse("M=00:0:0:0:0\n") == Cmd::Mouse); // ...except a hand-typed newline
// -0 is a hand-typed oddity, not an ambiguity.
CHECK(parse("M=00:-0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.dx, 0);
}
CASE("the board refuses to render a mouse frame it would not parse");
{
char buf[64];
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, 0), 12u);
CHECK_STR(buf, "M=00:0:0:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x01, 30, -4, 0, 0) > 0);
CHECK_STR(buf, "M=01:30:-4:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x1f, -127, 127, -127, 127) > 0);
CHECK_STR(buf, "M=1f:-127:127:-127:127");
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0x20, 0, 0, 0, 0), 0u);
CHECK_STR(buf, ""); // nothing half-written
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 128, 0, 0, 0), 0u);
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, -128), 0u);
// A buffer that is too small yields 0 and an empty string, never a truncated frame:
// a frame cut in half is a command the board did not receive, on a target that is
// waiting for the rest of it.
char tiny[12];
CHECK_EQ(kr::proto::formatMouse(tiny, sizeof(tiny), 0, 127, 127, 127, 127), 0u);
CHECK_STR(tiny, "");
}
CASE("a rendered mouse frame parses back to the same report");
{
const uint8_t bs[] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x1f};
const int8_t vs[] = {0, 1, -1, 127, -127, 42, -42};
for (size_t b = 0; b < sizeof(bs); b++) {
for (size_t i = 0; i < sizeof(vs); i++) {
for (size_t j = 0; j < sizeof(vs); j++) {
char buf[64];
const size_t n = kr::proto::formatMouse(buf, sizeof(buf), bs[b], vs[i], vs[j],
(int8_t)(-(int)vs[i]), vs[j]);
if (n == 0) { CHECK(false); continue; }
Message m;
CHECK(kr::proto::parse(buf, n, m) == Cmd::Mouse);
CHECK_EQ(m.buttons, bs[b]);
CHECK_EQ(m.dx, vs[i]);
CHECK_EQ(m.dy, vs[j]);
CHECK_EQ(m.wheel, -vs[i]);
CHECK_EQ(m.pan, vs[j]);
CHECK(n <= kr::kWsMessageCap);
}
}
}
}
CASE("error codes above 9 are not truncated to their last digit");
{
char buf[96];
// The single-digit codes must render exactly as they always did, or every client
// that branches on them breaks.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 1, "x"), 4u);
CHECK_STR(buf, "E1:x");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 9, "no key"), 9u);
CHECK_STR(buf, "E9:no key");
// 10 and 11 are the mouse's, and `code % 10` used to turn them into E0 -- a code no
// client would recognise, on the one path where a client needs to know.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, "off"), 7u);
CHECK_STR(buf, "E10:off");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
CHECK_STR(buf, "E11:boot");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
CHECK_STR(buf, "E0:z");
// Never a truncated frame: a client cannot parse half of one, and cannot tell an
// over-long error from a real one.
CHECK_EQ(kr::proto::formatError(buf, 7, 10, "too long"), 0u);
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, ""), 4u);
CHECK_STR(buf, "E10:");
}
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);
}
CASE("the page's mouse constants are the board's mouse constants");
{
// Same hand-copied-number hazard as the text ones: if the page thinks one report
// carries 255 of something, the board refuses every frame at 128.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[80];
snprintf(needle, sizeof(needle), "const MOUSE_DELTA_MAX = %d;", (int)kr::kMouseDeltaMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const MOUSE_BUTTON_MASK = 0x%x;",
(unsigned)kr::kMouseButtonMask);
CHECK(html.find(needle) != std::string::npos);
// The page must build the frame the firmware parses, with all five fields -- a
// missing one is a refused frame, and the page would look like it was moving the
// mouse while nothing arrived.
CHECK(html.find("function mouseFrame(buttons, dx, dy, wheel = 0, pan = 0)") !=
std::string::npos);
CHECK(html.find("\":\" + dx + \":\" + dy + \":\" + wheel + \":\" + pan") !=
std::string::npos);
// DOM MouseEvent.button -> report bit. The DOM numbers are not in HID order: 1 is
// the middle button and 2 is the right one. A page that got these the wrong way round
// would send a right-click as a middle-click, parse cleanly, and be wrong -- the
// Alt+Tab failure all over again, so the mapping is pinned here and in relayctl.
CHECK(html.find("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10") != std::string::npos);
// The mouse has to be released by the same fail-safes the keys are.
CHECK(html.find("dropMouse(\"disarmed\")") != std::string::npos);
CHECK(html.find("dropMouse(\"tab lost focus\")") != std::string::npos);
CHECK(html.find("dropEverything(\"tab hidden\")") != std::string::npos);
// ...and the board's own release funnel must be the one that releases the buttons
// when the page could not.
std::string out = readFile("src/output.h");
if (out.empty()) out = readFile("../src/output.h");
CHECK(!out.empty());
CHECK(out.find("mouseState.releaseAll()") != std::string::npos);
CHECK(out.find("mouse::kReportClear") != std::string::npos);
CHECK(out.find("typer.cancel()") != std::string::npos);
}
CASE("the status frame: the TLS identity renders, and the frame fits its cap");
{
// statusJson() lives in status.h so that this can run at all. It used to sit behind
// #include <WiFi.h>, which left the number that decides whether the controller's whole
// picture of the board arrives intact -- kr::kStatusJsonCap -- unmeasured by anything on the
// host. A frame that does not fit renders as *nothing* (the function returns 0 and the
// caller sends nothing), so an added field would have looked like a dropped socket or a
// stalled page, three commits away from the cause.
StatusSnapshot s;
char json[kr::kStatusJsonCap];
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
// A default snapshot: no TLS, and no label for an identity that is not there.
size_t n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(n < kr::kStatusJsonCap);
CHECK(strstr(json, "\"tls\":0") != nullptr);
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
CHECK_EQ(kr::kStatusFingerprintHex, 16);
// The identity renders as `"tls":1` plus a `fp` of exactly 16 hex characters -- the
// first kr::kStatusFingerprintBytes of the digest, not the whole 64: this field is a label to
// eyeball against `cert`, and the full value is the console's job.
s.tls = true;
s.fp = fp;
n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"tls\":1") != nullptr);
CHECK(strstr(json, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
CHECK(strstr(json, "0de5dae2223") == nullptr);
// Upper case hex is as renderable as lower case: the board prints lower, but a value
// that arrived upper case must survive the boundary unchanged rather than be cut to "".
s.fp = "BA7816BF8F01CFEA0123456789abcdef";
n = statusJson(s, json, sizeof(json));
CHECK(strstr(json, "\"fp\":\"BA7816BF8F01CFEA\"") != nullptr);
// tls:0 wins over a filled-in label. A page that drew a fingerprint next to "no TLS" is
// drawing an picture of security the board does not have.
s.tls = false;
n = statusJson(s, json, sizeof(json));
CHECK(strstr(json, "\"tls\":0") != nullptr);
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
// The JSON boundary: a stored value that had gone non-hex is cut where it stops being
// hex instead of pasted in, because a quote inside a field is a field forgery -- the same
// reason the SSID below is sanitised.
s.tls = true;
s.fp = "01234567" "\"armed\":0,\"x\":\"";
n = statusJson(s, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"fp\":\"01234567\"") != nullptr);
int armedFields = 0;
for (const char *p = json; (p = strstr(p, "\"armed\"")) != nullptr; p++) armedFields++;
CHECK_EQ(armedFields, 1);
// An SSID is somebody else's bytes on that same boundary: quotes, backslashes, control
// characters and 0x7F never reach the frame, and an over-long one is cut at the buffer
// rather than at the frame. (Behaviour inherited from control_server.cpp; it is now
// testable, so it is now tested.)
StatusSnapshot hostile;
hostile.ssid = "a\"b\\c" "\x01" "d" "\x7f" "e";
n = statusJson(hostile, json, sizeof(json));
CHECK(n > 0);
CHECK(strstr(json, "\"ssid\":\"abcde\"") != nullptr);
CHECK(strchr(json, '\\') == nullptr);
hostile.ssid = "0123456789012345678901234567890123456789012345678901234567890123456789";
n = statusJson(hostile, json, sizeof(json));
CHECK(n > 0);
// 39 characters: jsonSafe() fills cap-1 of the 40-byte buffer it is given.
CHECK(strstr(json, "\"ssid\":\"012345678901234567890123456789012345678\"") != nullptr);
// The longest frame the board can legally put on the wire: a 32-character SSID (the
// 802.11 limit, and staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button, and
// the whole 16-hex `fp`. This is the arithmetic config.h quotes -- measured here rather
// than believed, with the headroom asserted as at least one more field's worth so the
// next field is a decision and not an overflow nobody looked for.
StatusSnapshot worst;
worst.version = kr::kFirmwareVersion;
worst.uptimeS = 0xFFFFFFFFu;
worst.usbHost = true;
worst.armed = true;
worst.netUp = true;
worst.net = "down"; // the longest of the three net modes
worst.ssid = "01234567890123456789012345678901";
worst.ip = "255.255.255.255";
worst.keysSent = 0xFFFFFFFFu;
worst.chordsSent = 0xFFFFFFFFu;
worst.dropped = 0xFFFFFFFFu;
worst.typedChars = 0xFFFFFFFFu;
worst.typing = 0xFFFF;
worst.rollover = true;
worst.mouse = true;
worst.mouseButtons = kr::kMouseButtonMask;
worst.mouseFrames = 0xFFFFFFFFu;
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
worst.defaultToken = true;
worst.apForced = true;
worst.tls = true;
worst.fp = fp;
char big[kr::kStatusJsonCap + 16];
const size_t longest = statusJson(worst, big, sizeof(big));
CHECK(longest > 0);
CHECK(longest <= kr::kStatusJsonCap);
CHECK(kr::kStatusJsonCap - longest >= 32);
CHECK_EQ(strlen(big), longest);
CHECK(big[0] == '{');
CHECK(big[longest - 1] == '}');
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
CHECK(strstr(big, "\"tls\":1") != nullptr);
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
// And the cap means it: one byte short renders nothing at all, which is the difference
// between a status that is missing and a status that is wrong.
CHECK_EQ(statusJson(worst, big, longest), 0);
CHECK_EQ(statusJson(worst, big, longest + 1), longest);
}
return report("proto");
}