/** * tests/test_tls_session.cpp -- the policy half of src/tls_session.h, walked on the * host. No mbed TLS, no Arduino, no LWIP: the header's pure section is exactly the * part that must compile without them (the Session class hides behind * KR_TLS && ESP_PLATFORM), and this file is the proof. If this suite ever needs an * mbedtls include to keep passing, the split has failed -- see AGENTS.md. * * What each check pins, and the failure it is defending: * * deadlinePassed -- the handshake/stall timeouts are uint32 clock maths; a plain * `now - start > limit` that is not written wrap-safe silently * un-timeouts every connection around the 49.7-day rollover. * admitConnection -- the kTlsMaxSessions refusal. The three outcomes and the order * they are decided in: the cap BEFORE the free slot, so an * over-cap board refuses rather than "finds" a slot that memory * was not reserved for. The refusal must not know which listener * a socket came from -- there is no transport argument here at * all, which is the point. * mapIo -- the table that decides "would block" vs "peer gone" vs "fatal" * for every mbedtls_ssl_* return. A WANT misread as fatal drops * working controllers; a fatal misread as WANT spins the loop. * The three literals it branches on are pinned against the real * library by static_assert in tls_session.cpp -- here the TABLE * is walked; there the NUMBERS are welded. * renderFatalAlert-- 7 bytes of somebody else's wire format (RFC 5246 alert record). * AGENTS.md's evdev lesson: test the table, not your confidence. * TxRing -- whole-frame-or-nothing staging. A half-queued WebSocket frame * corrupts the keystroke channel; this class makes that the only * failure it cannot produce. It is now the outbound queue of BOTH * transports (kr::Conn, src/conn.h), not just the TLS one. * * Phase 02 task 04 removed `WriteQueue` from this header: the transport view in conn.h * queues a head and a body through the ring plus kr::FlashSend instead, so keeping a second * streaming cursor here would be exactly the second path this phase exists to delete. The * "stream one long segment in bounded pieces" coverage moved with it, to tests/test_conn.cpp. */ #include #include "test.h" #include "tls_session.h" using kr::tls::admitConnection; using kr::tls::Admit; using kr::tls::deadlinePassed; using kr::tls::Io; using kr::tls::kFatalAlertLen; using kr::tls::kMbedPeerCloseNotify; using kr::tls::kMbedWantRead; using kr::tls::kMbedWantWrite; using kr::tls::mapIo; using kr::tls::renderFatalAlert; using kr::tls::TxRing; int main() { // ---------------------------------------------------------------- deadline CASE("deadline: basics"); CHECK(!deadlinePassed(1000, 1000, 5000)); // just started CHECK(!deadlinePassed(5999, 1000, 5000)); // one ms short CHECK(!deadlinePassed(6000, 1000, 5000)); // exactly the limit has NOT passed CHECK(deadlinePassed(6001, 1000, 5000)); // and one past it has CHECK(deadlinePassed(1, 0, 0)); // zero-length limit: instantly stale CASE("deadline: uint32 clock rollover"); // The board's millis() wraps at 2^32. All four of these evaluate "backwards" under // unsigned arithmetic and only read correctly through the signed-delta form. constexpr uint32_t kNear = 0xFFFFFFF0u; // 16 ms before wrap CHECK(!deadlinePassed(kNear + 10, kNear, 64)); // 10 ms in, no wrap yet CHECK(!deadlinePassed(0x10, kNear, 32)); // wrapped: 32 ms in, limit 32... CHECK(deadlinePassed(0x20, kNear, 16)); // wrapped: 48 ms > 16 ms limit CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0)); // 0xFFFFFFF0 elapsed, limit hit CHECK(deadlinePassed(0x0FFFFFFF, 0x10, 0xFFFFFFF0 - 1)); CHECK(!deadlinePassed(5000, 1000, 0x7FFFFFFF)); // huge limit stays sane CASE("deadline: handshake shape"); // kTlsHandshakeMs is the scan bound: "opened and never finished a handshake" means // exactly this comparison going true, every tick, from Session::poll(). CHECK(deadlinePassed(10001, 0, kr::kTlsHandshakeMs)); CHECK(!deadlinePassed(10000, 0, kr::kTlsHandshakeMs)); CHECK_EQ(kr::kTlsHandshakeMs, 10000); // ------------------------------------------------------------------ admit CASE("admit: the truth table"); // Two slots: controller, spare. Nothing else exists, and the function has no // transport parameter to consult -- listener-independence by construction. CHECK(admitConnection(false, 0, 2) == Admit::Controller); // empty board CHECK(admitConnection(false, 1, 2) == Admit::Controller); // spare only: still room CHECK(admitConnection(true, 1, 2) == Admit::Spare); // controller: one spare CHECK(admitConnection(true, 2, 2) == Admit::Refused); // the third connection CHECK(admitConnection(false, 2, 2) == Admit::Refused); // cap checked BEFORE slot CHECK(admitConnection(true, 0, 2) == Admit::Spare); // count says spare live... // ...but not busy: spare CASE("admit: cap sizes"); CHECK(admitConnection(false, 0, 1) == Admit::Controller); CHECK(admitConnection(true, 1, 1) == Admit::Refused); // a 1-slot board: no spare CHECK(admitConnection(true, 1, 3) == Admit::Spare); CHECK(admitConnection(true, 2, 3) == Admit::Spare); CHECK(admitConnection(true, 3, 3) == Admit::Refused); // The ceiling this project ships is two, and the RAM comment in config.h is written // against that number: change one, the other has to be re-derived, so pin it here. CHECK_EQ(kr::kTlsMaxSessions, 2); // ------------------------------------------------------------------- mapIo CASE("mapIo: read/write/handshake verdicts"); CHECK(mapIo(1) == Io::Bytes); CHECK(mapIo(4096) == Io::Bytes); // a full chunk CHECK(mapIo(7) == Io::Bytes); // a SHORT write is still Bytes -- // the caller keeps framing state CHECK(mapIo(0) == Io::Closed); // mbed TLS 3.6: 0 = read end closed CHECK(mapIo(kMbedWantRead) == Io::Want); CHECK(mapIo(kMbedWantWrite) == Io::Want); CHECK(mapIo(kMbedPeerCloseNotify) == Io::Closed); // acknowledged end CHECK(mapIo(-0x0050) == Io::Error); // NET_CONN_RESET CHECK(mapIo(-0x2700) == Io::Error); // SSL fatal class CHECK(mapIo(-0x7B00) == Io::Error); // NEW_SESSION_TICKET: client-side // only; unmapped lands fatal, never // silently "keep going" CHECK(mapIo(-1) == Io::Error); // The numbers themselves. If the shipped core ever renumbers these, the // static_asserts in tls_session.cpp stop the FIRMWARE build; these stop this suite, // so "the table drifted from the library" can never be a green run anywhere. CHECK_EQ(kMbedWantRead, -0x6900); CHECK_EQ(kMbedWantWrite, -0x6880); CHECK_EQ(kMbedPeerCloseNotify, -0x7880); CASE("mapIo: Want never reads as Closed and vice versa"); // The distinction IS the non-blocking contract: a WANT treated as Closed drops a // working controller every time WiFi is merely slow. CHECK(mapIo(kMbedWantRead) != Io::Closed); CHECK(mapIo(0) != Io::Want); CHECK(mapIo(kMbedWantWrite) != Io::Error); // ------------------------------------------------------------------- alert CASE("fatal alert record"); uint8_t alert[16]; CHECK_EQ(renderFatalAlert(alert, sizeof(alert)), (size_t)kFatalAlertLen); CHECK_EQ(alert[0], 0x15); // record type 21: alert CHECK_EQ(alert[1], 0x03); // version major 3 CHECK_EQ(alert[2], 0x01); // version minor 1 (TLS 1.1+ field value, per record) CHECK_EQ(alert[3], 0x00); // length, high byte CHECK_EQ(alert[4], 0x02); // length, low byte: two bytes of payload CHECK_EQ(alert[5], 0x02); // alert level: fatal CHECK_EQ(alert[6], 0x28); // description 40 == 0x28: handshake_failure, RFC 5246 // ยง7.2.2 -- written here in decimal ON PURPOSE: the byte // that was first drafted as "0x40 because forty" was the // whole reason this table got its own test. CASE("fatal alert: refuses rather than truncates"); CHECK_EQ(renderFatalAlert(alert, kFatalAlertLen - 1), 0); CHECK_EQ(renderFatalAlert(nullptr, 16), 0); uint8_t tight[kFatalAlertLen]; memset(tight, 0, sizeof(tight)); CHECK_EQ(renderFatalAlert(tight, kFatalAlertLen), kFatalAlertLen); CHECK_EQ(tight[0], 0x15); size_t n = 7; const uint8_t head[] = "HTTP/1.1 200 OK\r\n\r\n"; // ------------------------------------------------------------------ TxRing CASE("tx ring: whole frame or nothing"); uint8_t store[64]; TxRing ring(store, sizeof(store)); CHECK_EQ(ring.count(), 0); CHECK_EQ(ring.capacity(), sizeof(store)); const char frame[] = "0123456789"; CHECK(ring.push(reinterpret_cast(frame), 11)); CHECK_EQ(ring.count(), 11); // Fill to exactly capacity minus 1... then a frame of 2 must bounce whole, with the // ring byte-identical to before the refusal. uint8_t pad[53] = {0}; CHECK(ring.push(pad, sizeof(pad))); CHECK_EQ(ring.count(), 64u); const char last[] = "ab"; CHECK(!ring.push(reinterpret_cast(last), 2)); CHECK_EQ(ring.count(), 64u); CHECK(memcmp(store, frame, 11) == 0); // refused push wrote nothing anywhere CHECK(!ring.push(reinterpret_cast(last), 1)); CHECK_EQ(ring.count(), 64u); CASE("tx ring: push bigger than capacity always refused"); ring.reset(); uint8_t big[65] = {0}; CHECK(!ring.push(big, sizeof(big))); CHECK_EQ(ring.count(), 0); CHECK(ring.push(big, sizeof(store))); // exactly full is the boundary and passes CASE("tx ring: head span, wrap, advance"); ring.reset(); uint8_t filler[60] = {0}; CHECK(ring.push(filler, sizeof(filler))); ring.advance(55); // head sits 9 bytes from the end CHECK_EQ(ring.count(), 5u); CHECK(ring.push(pad, 10)); // fills 4 more to the end, wraps for 6 CHECK_EQ(ring.count(), 15u); CHECK(ring.head(64, &n) == store + 55); // the span runs to the wrap: 5 old + 4 new, CHECK_EQ(n, 9); // contiguous -- 9, not 5: the ring packs ring.advance(9); // after the head, not after the old bytes CHECK(ring.head(64, &n) == store); // ...and the rest continues at index 0 CHECK_EQ(n, 6); ring.advance(6); CHECK_EQ(ring.count(), 0); CHECK(ring.head(64, &n) == nullptr); CHECK_EQ(n, 0); CASE("tx ring: head cap bounds the write (one record per chunk)"); ring.reset(); uint8_t bulk[64] = {7}; CHECK(ring.push(bulk, sizeof(bulk))); CHECK(ring.head(3, &n) == store); CHECK_EQ(n, 3); // The resume rule: the same head after a refused (advance-less) write yields the // identical bytes -- which is what makes a WANT_WRITE retry legal. kConnWriteChunk is // the cap kr::Conn hands here, for either transport. CHECK(ring.head(kr::kConnWriteChunk, &n) == store); CHECK_EQ(n, 64u); CASE("tx ring: default-constructed holds nothing and refuses everything"); // kr::Conn is bound after it is constructed, so an unseated ring has to exist safely: // no modulo by zero, no head, nothing accepted. The alternative is a crash on the first // tick after boot, before any socket has arrived. TxRing bare; CHECK_EQ(bare.count(), 0); CHECK(bare.head(64, &n) == nullptr); CHECK_EQ(n, 0); CHECK(!bare.push(reinterpret_cast(head), 4)); bare.advance(4); // must not wrap the head or the count CHECK_EQ(bare.count(), 0); CHECK_EQ(bare.head(64, &n), nullptr); // Capacity 0 is coerced to 1 rather than kept: every index in this class is a modulo, // and a ring that cannot exist is a crash on the first tick, before any socket arrives. // The one byte it can hold is never delivered anywhere, because nothing is ever asked // of an unseated Conn. TxRing zero(store, 0); CHECK_EQ(zero.capacity(), 1u); CHECK(zero.push(reinterpret_cast(head), 1)); CHECK(!zero.push(reinterpret_cast(head), 2)); zero.reset(); CHECK(zero.head(64, &n) == nullptr); CHECK_EQ(n, 0u); CASE("tx ring: advance clamps to count; reset empties"); ring.reset(); CHECK(ring.push(bulk, 8)); ring.advance(99); CHECK_EQ(ring.count(), 0); CHECK(ring.push(bulk, 8)); ring.reset(); CHECK_EQ(ring.count(), 0); CHECK(ring.push(bulk, 4)); // usable after reset, head back at 0 CHECK(ring.head(64, &n) == store); CHECK_EQ(n, 4); CASE("tx ring: zero-length push is a no-op success"); ring.reset(); CHECK(ring.push(reinterpret_cast(frame), 0)); CHECK_EQ(ring.count(), 0); CHECK(!ring.push(nullptr, 4)); // nothing to copy: refused, never guessed CASE("tx ring: reseat moves it onto new storage and empties it"); // Conn re-binds a slot between sockets; a stale byte left behind from the previous // occupant would be delivered to the new one. uint8_t other[32] = {0}; ring.reset(); CHECK(ring.push(bulk, 8)); CHECK_EQ(ring.count(), 8u); ring.reseat(other, sizeof(other)); CHECK_EQ(ring.capacity(), sizeof(other)); CHECK_EQ(ring.count(), 0); CHECK(ring.head(64, &n) == nullptr); CHECK(ring.push(bulk, 5)); CHECK_EQ(ring.count(), 5u); CHECK(ring.head(64, &n) == other); // the bytes went to the NEW buffer CHECK_EQ(store[0], bulk[0]); // and the old one still holds what it was given ring.reseat(other, 0); // capacity 0 through reseat is the same safe 1 CHECK_EQ(ring.capacity(), 1u); CASE("config: the constants this layer promises"); // kConnTxCap must hold the largest single frame the board writes: a status frame plus // its WebSocket header. If statusJson ever outgrows this arithmetic, the queue would // start dropping status frames -- visible, but this pin makes it a test failure first. // (Task 04 renamed it from kTlsTxRingCap: it is now the queue of both transports.) CHECK(kr::kConnTxCap >= kr::kStatusJsonCap + 16); CHECK(kr::kConnWriteChunk < 16384); // one record per write: no library fragmentation return report("test_tls_session"); }