// tests for the control protocol: tests/test_proto.cpp #include #include #include #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); CHECK(parse("X") == Cmd::Takeover); // the pending claim's one deliberate act // hand-typed clients end lines with \n: tolerated CHECK(parse("A1\n") == Cmd::Arm); CHECK(parse("R\r\n") == Cmd::ReleaseAll); CHECK(parse("X\r\n") == Cmd::Takeover); // tolerated here exactly like the others } CASE("the takeover frame: what a claimant puts on the wire is what the board parses"); { // There is no formatX(): the client side of `X` is the single byte, and this is // the round trip -- the exact bytes the page or relayctl will send, run through // the firmware's own parser, plus the name the diagnostics print. const char frame[] = "X"; Message m; CHECK(kr::proto::parse(frame, sizeof(frame) - 1, m) == Cmd::Takeover); CHECK(std::string(kr::proto::name(Cmd::Takeover)) == "takeover"); // Anything else starting with X is not a takeover and not a conversation. CHECK(kr::proto::parse("XK", 2, m) == Cmd::Bad); } 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("XX") == Cmd::Bad); // `X` is one command; trailing bytes are Bad CHECK(parse("X1") == Cmd::Bad); // ... even bytes that look meaningful CHECK(parse("X ") == Cmd::Bad); // ... or not: one frame, one command 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(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('4' + (i % 6)); big[o++] = ','; } big[o - 1] = '\0'; const bool withinCap = n <= static_cast(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=":". 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"); // E12 is the incumbent's sentence ("you were dropped"), and it renders at full // width like 10/11 -- a client that was told "E1" by truncation would tell its // operator the passphrase failed instead of the bridge was taken. CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 12, "session taken over"), 22u); CHECK_STR(buf, "E12:session taken over"); 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 , 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, no label for an identity that is not there, and a // free bridge with no handover behind it. 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(strstr(json, "\"busy\":0") != nullptr); CHECK(strstr(json, "\"tkov\":0") != nullptr); CHECK_EQ(kr::kStatusFingerprintBytes, 8); CHECK_EQ(kr::kStatusFingerprintHex, 16); // The bridge, in the only terms the board may know: a link boolean and a // counter. busy:1 says "a controller holds the keyboard" -- to a /status poller, // someone else; to the adopted controller, itself -- and tkov counts completed // handovers. There is no field that could say WHO, and walking these two is what // keeps the next "just add the client IP" from passing review unnoticed: the // README's never-stored list is about exactly this temptation. StatusSnapshot held; held.busy = true; held.takeovers = 3; n = statusJson(held, json, sizeof(json)); CHECK(n > 0); CHECK(strstr(json, "\"busy\":1") != nullptr); CHECK(strstr(json, "\"tkov\":3") != nullptr); CHECK(strstr(json, "addr") == nullptr); // the only ip here is the board's own CHECK(strstr(json, "agent") == nullptr); CHECK(strstr(json, "peer") == nullptr); // 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, // `busy` set, `tkov` at UINT32_MAX, 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.busy = true; worst.takeovers = 0xFFFFFFFFu; 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); CHECK(strstr(big, "\"busy\":1") != nullptr); CHECK(strstr(big, "\"tkov\":4294967295") != 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"); }