// tests for the control protocol: tests/test_proto.cpp #include #include #include #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(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("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"); }