phase: 03_session_takeover
**Phase 03 `session_takeover` — final verification pass. All green.**
All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.
**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.
**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).
**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.
**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.
**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
This commit is contained in:
+1
-1
@@ -36,7 +36,7 @@ images=(
|
||||
)
|
||||
|
||||
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
|
||||
test_tls_pem test_tls_session test_conn)
|
||||
test_tls_pem test_tls_session test_conn test_claim)
|
||||
|
||||
build_and_run() {
|
||||
local cxx="$1"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// tests for the pending takeover claim: tests/test_claim.cpp
|
||||
//
|
||||
// Phase 03 task 01 opens one door a crack: the spare socket may now complete the
|
||||
// WebSocket handshake and authenticate as a *pending claim* instead of getting a 503.
|
||||
// Nothing else is on the wire yet, so everything that decides what that socket is
|
||||
// allowed to be -- which role, which commands, which clock -- lives in src/claim.h,
|
||||
// and this suite walks it: the whitelist as a matrix (every Cmd, both auth states,
|
||||
// both roles), the deadline arithmetic on both sides of the millis() rollover, and
|
||||
// the claim's buffer against the widest frame it may receive -- refused, not
|
||||
// buffered, measured through the real codec rather than asserted from the constant.
|
||||
#include <string.h>
|
||||
|
||||
#include "claim.h"
|
||||
#include "config.h"
|
||||
#include "proto.h"
|
||||
#include "test.h"
|
||||
#include "ws_codec.h"
|
||||
|
||||
using kr::claim::acceptsUpgrade;
|
||||
using kr::claim::expired;
|
||||
using kr::claim::honours;
|
||||
using kr::claim::ViewerRole;
|
||||
using kr::proto::Cmd;
|
||||
using kr::proto::Message;
|
||||
using kr::proto::parse;
|
||||
|
||||
namespace {
|
||||
|
||||
// Every value proto::Cmd has. The matrix below walks it, so a new command added to
|
||||
// the protocol is a new row that must be judged allowed-or-refused for a claim --
|
||||
// the whitelist decides by name, and this list is what notices a name appears.
|
||||
constexpr Cmd kAll[] = {Cmd::None, Cmd::Hello, Cmd::Auth,
|
||||
Cmd::Arm, Cmd::Disarm, Cmd::State,
|
||||
Cmd::SecureAttention, Cmd::Chord, Cmd::Text,
|
||||
Cmd::Mouse, Cmd::ReleaseAll, Cmd::KeepAlive,
|
||||
Cmd::Takeover, Cmd::Bad};
|
||||
constexpr size_t kNumCmds = sizeof(kAll) / sizeof(kAll[0]);
|
||||
|
||||
// One masked client text frame, the only shape the codec accepts, sized for a
|
||||
// payload below 126 bytes (the claim's whole budget is 64).
|
||||
size_t maskedTextFrame(uint8_t *out, const uint8_t *text, size_t n) {
|
||||
const uint8_t key[4] = {0x37, 0xfa, 0x11, 0xde};
|
||||
size_t o = 0;
|
||||
out[o++] = 0x81; // FIN + text
|
||||
out[o++] = static_cast<uint8_t>(0x80 | n); // masked, 7-bit length
|
||||
for (size_t i = 0; i < 4; i++) out[o++] = key[i];
|
||||
for (size_t i = 0; i < n; i++) out[o++] = static_cast<uint8_t>(text[i] ^ key[i & 3]);
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CASE("the whitelist, unauthenticated: T is the only door");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
CHECK_EQ(honours(ViewerRole::Claim, false, kAll[i]), kAll[i] == Cmd::Auth);
|
||||
}
|
||||
|
||||
CASE("the whitelist, authenticated: T, K and the deliberate X -- nothing else");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
const bool allowed = kAll[i] == Cmd::Auth || kAll[i] == Cmd::KeepAlive ||
|
||||
kAll[i] == Cmd::Takeover;
|
||||
CHECK_EQ(honours(ViewerRole::Claim, true, kAll[i]), allowed);
|
||||
}
|
||||
// X is allowed ONLY here, in the authenticated branch: it is the one deliberate act
|
||||
// the door exists for, and control_server.cpp::handOverClaim -- the only path it
|
||||
// triggers -- runs the release funnel and lands the board DISARMED. The claim socket
|
||||
// that reaches it has already proven the passphrase.
|
||||
CHECK(honours(ViewerRole::Claim, true, Cmd::Takeover));
|
||||
// Refused by name, because this list is the safety argument: reaching the target,
|
||||
// disturbing the incumbent, or moving the arming line are all refused here.
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Arm)); // no arm
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Disarm));
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::State)); // no keystrokes
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::SecureAttention)); // no Ctrl+Alt+Del
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Chord));
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Text)); // no paste
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Mouse)); // no pointer
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::ReleaseAll)); // no R either
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Hello)); // no status
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Bad)); // not a conversation
|
||||
|
||||
CASE("a Readonly slot honours no frame at all, authenticated or not");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
CHECK(!honours(ViewerRole::Readonly, false, kAll[i]));
|
||||
CHECK(!honours(ViewerRole::Readonly, true, kAll[i]));
|
||||
}
|
||||
// Including the takeover ask: a slot that never did the upgrade cannot claim a bridge.
|
||||
CHECK(!honours(ViewerRole::Readonly, true, Cmd::Takeover));
|
||||
|
||||
CASE("one claim at a time; a read-only slot may be turned into one");
|
||||
CHECK(acceptsUpgrade(ViewerRole::Readonly));
|
||||
CHECK(!acceptsUpgrade(ViewerRole::Claim));
|
||||
|
||||
CASE("deadlines: authenticate on the controller's clock, then wait our own");
|
||||
{
|
||||
const uint32_t anchor = 1234567;
|
||||
// Unauthenticated: exactly the controller's kAuthTimeoutMs, strictly greater.
|
||||
CHECK(!expired(false, anchor, anchor));
|
||||
CHECK(!expired(false, anchor + kr::kAuthTimeoutMs, anchor));
|
||||
CHECK(expired(false, anchor + kr::kAuthTimeoutMs + 1, anchor));
|
||||
// Authenticated: the auth clock no longer applies -- a claim that got its `OK`
|
||||
// at the worst legal moment is still a claim -- and kClaimTimeoutMs governs.
|
||||
CHECK(!expired(true, anchor + kr::kAuthTimeoutMs + 1, anchor));
|
||||
CHECK(!expired(true, anchor + kr::kClaimTimeoutMs, anchor));
|
||||
CHECK(expired(true, anchor + kr::kClaimTimeoutMs + 1, anchor));
|
||||
CHECK(kr::kClaimTimeoutMs > kr::kAuthTimeoutMs); // 15 s for a click, 5 s for T
|
||||
// Across the 49.7-day millis() rollover the arithmetic is elapsed time, not
|
||||
// magnitude: a live claim is not evicted and a dead one is not immortal.
|
||||
const uint32_t nearWrap = 0xFFFFFF00u;
|
||||
CHECK(!expired(false, nearWrap + 4000, nearWrap));
|
||||
CHECK(expired(false, nearWrap + kr::kAuthTimeoutMs + 1, nearWrap)); // wraps
|
||||
CHECK(!expired(true, nearWrap + 14000, nearWrap));
|
||||
CHECK(expired(true, nearWrap + kr::kClaimTimeoutMs + 1, nearWrap)); // wraps
|
||||
}
|
||||
|
||||
CASE("the claim's budget: every legal frame fits, anything larger is refused");
|
||||
{
|
||||
// The sizing claim, pinned as numbers: the widest frame a claim may receive is
|
||||
// `T<token>` at kTokenMax + 1, the cap is that with headroom, and it is exactly
|
||||
// one quarter of the controller's kWsMessageCap (config.h carries the reasoning;
|
||||
// a draft said sixteenth, the arithmetic says four, and the arithmetic wins).
|
||||
CHECK(kr::kTokenMax + 1 <= kr::kClaimMsgCap);
|
||||
CHECK_EQ(kr::kWsMessageCap / kr::kClaimMsgCap, 4u);
|
||||
CHECK(kr::kClaimMsgCap < kr::kWsMessageCap);
|
||||
|
||||
// The widest legal T frame parses, and fits the buffer with room to spare.
|
||||
char widest[ kr::kTokenMax + 2];
|
||||
widest[0] = 'T';
|
||||
memset(widest + 1, 'a', kr::kTokenMax);
|
||||
widest[kr::kTokenMax + 1] = '\0';
|
||||
Message m;
|
||||
CHECK(parse(widest, kr::kTokenMax + 1, m) == Cmd::Auth);
|
||||
CHECK(strlen(widest) < kr::kClaimMsgCap);
|
||||
}
|
||||
|
||||
CASE("an oversized claim frame is refused by the codec, not buffered");
|
||||
{
|
||||
uint8_t msg[kr::kClaimMsgCap + 16];
|
||||
memset(msg, 'z', sizeof(msg));
|
||||
|
||||
// Exactly at the cap: the reader completes (the frame is refused or accepted by
|
||||
// the whitelist later -- the codec's answer is "it fits").
|
||||
uint8_t buf[kr::kClaimMsgCap];
|
||||
kr::ws::Reader r{buf, sizeof(buf)};
|
||||
uint8_t frame[256];
|
||||
size_t n = maskedTextFrame(frame, msg, kr::kClaimMsgCap);
|
||||
kr::ws::In in = kr::ws::In::NeedMore;
|
||||
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r.feed(frame[i]);
|
||||
CHECK(in == kr::ws::In::Text);
|
||||
|
||||
// One byte over: an error, not a truncation, not a growing buffer. The board's
|
||||
// RAM is not somebody else's allocation request.
|
||||
kr::ws::Reader r2{buf, sizeof(buf)};
|
||||
n = maskedTextFrame(frame, msg, kr::kClaimMsgCap + 1);
|
||||
in = kr::ws::In::NeedMore;
|
||||
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r2.feed(frame[i]);
|
||||
CHECK(in == kr::ws::In::Error);
|
||||
CHECK(r2.error() == kr::ws::Err::TooLong);
|
||||
}
|
||||
|
||||
CASE("X is the takeover command, and the frames the whitelist names");
|
||||
{
|
||||
// Task 02 put `X` on the wire: it parses to Takeover (trailing bytes are Bad --
|
||||
// test_proto.cpp walks that in full), and it is the only frame that changed side
|
||||
// of the whitelist since task 01. The rest still parse as the whitelist comment
|
||||
// says they do, because that comment and this list are the same argument.
|
||||
Message m;
|
||||
CHECK(parse("X", 1, m) == Cmd::Takeover);
|
||||
CHECK(parse("XX", 2, m) == Cmd::Bad);
|
||||
// And the frames named by the whitelist comment really are the commands named:
|
||||
CHECK(parse("K", 1, m) == Cmd::KeepAlive);
|
||||
CHECK(parse("Tchangeme", 9, m) == Cmd::Auth);
|
||||
CHECK(parse("A1", 2, m) == Cmd::Arm);
|
||||
CHECK(parse("A0", 2, m) == Cmd::Disarm);
|
||||
CHECK(parse("R", 1, m) == Cmd::ReleaseAll);
|
||||
CHECK(parse("C", 1, m) == Cmd::SecureAttention);
|
||||
CHECK(parse("H", 1, m) == Cmd::Hello);
|
||||
CHECK(parse("S=00:", 5, m) == Cmd::State);
|
||||
CHECK(parse("Y=02:04", 7, m) == Cmd::Chord);
|
||||
CHECK(parse("P=0:6162", 8, m) == Cmd::Text);
|
||||
CHECK(parse("M=00:0:0:0:0", 12, m) == Cmd::Mouse);
|
||||
}
|
||||
|
||||
return report("test_claim");
|
||||
}
|
||||
+6
-6
@@ -67,7 +67,7 @@ namespace {
|
||||
// tests/test_relayctl.py ("the page the transport sends"). The page carries one
|
||||
// 19-byte #if KR_TLS build marker and both spellings are the same length, so this
|
||||
// number is build-independent -- a fact test_relayctl pins, not a hope.
|
||||
constexpr size_t kServedPageBytes = 51626;
|
||||
constexpr size_t kServedPageBytes = 59286;
|
||||
|
||||
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
|
||||
struct DrainLog {
|
||||
@@ -311,10 +311,10 @@ int main() {
|
||||
CHECK_EQ(fs.sent(), 20);
|
||||
|
||||
CASE("flash send: the real page, on the real budget");
|
||||
// 51 626 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
|
||||
// 59 286 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
|
||||
// and kPageBytesPerTick per tick: the exact numbers the device check compares against.
|
||||
{
|
||||
// A stand-in of the served page's size (51 626 B, the `page bytes:` line the gate
|
||||
// A stand-in of the served page's size (59 286 B, the `page bytes:` line the gate
|
||||
// prints). tests/test_relayctl.py pins this constant against the raw string in
|
||||
// src/web_page.h, so if the page changes and this number does not, a check fails --
|
||||
// the same "test the table" rule as the mouse bits and the evdev codes.
|
||||
@@ -342,10 +342,10 @@ int main() {
|
||||
CHECK_EQ(total, sizeof(big));
|
||||
CHECK(worstPiece <= kPageChunkBytes);
|
||||
CHECK(worstTick <= kPageBytesPerTick);
|
||||
// 51 626 / 1460 = 36 chunks; / 5840 per tick = 9 ticks. If either number changes in
|
||||
// 59 286 / 1460 = 41 chunks; / 5840 per tick = 11 ticks. If either number changes in
|
||||
// config.h this line moves, and the report has to move with it.
|
||||
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 9);
|
||||
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 36);
|
||||
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 11);
|
||||
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 41);
|
||||
}
|
||||
|
||||
CASE("config: the sizing this layer promises");
|
||||
|
||||
@@ -141,6 +141,9 @@ const expose = `
|
||||
get armed() { return armed; }, set armed(v) { armed = v; },
|
||||
get authed() { return authed; }, set authed(v) { authed = v; },
|
||||
get typing() { return typing; },
|
||||
get autoReconnect() { return autoReconnect; },
|
||||
get busyKnown() { return busyKnown; },
|
||||
get takenOver() { return takenOver; },
|
||||
};`;
|
||||
|
||||
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
|
||||
@@ -626,6 +629,116 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
|
||||
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
|
||||
"a new status re-renders the new fingerprint");
|
||||
|
||||
console.log("the busy board and the take-over");
|
||||
// Phase 03, page side. The board reports "held" to a socket it authenticated but
|
||||
// did not adopt (the status frame belongs to the controller alone), and to a
|
||||
// session it displaces (E12). The page's whole job is to make that visible, offer
|
||||
// one deliberate click, and NOT fight: no retry loop, no auto-reclaim, and --
|
||||
// until the click -- not one frame about the bridge.
|
||||
|
||||
// `busy` in a status frame, seen by a page that is not the controller, is the
|
||||
// link pill's third honest state. A controller reading busy:1 is reading itself,
|
||||
// and keeps "linked" -- the OK handler says so; applyStatus must not contradict it.
|
||||
byId("takeover").hidden = true;
|
||||
x.authed = false; x.armed = false;
|
||||
x.onFrame('S{"armed":0,"usb":1,"typing":0,"busy":1}');
|
||||
checkEq(byId("pLink").textContent, "held by another controller",
|
||||
"a busy status frame drives the link pill's third state");
|
||||
check(byId("pLink").className.includes("bad"),
|
||||
"and it is not the calm 'linked' state");
|
||||
checkEq(x.busyKnown, true, "and the page now knows the board is held");
|
||||
x.onFrame("OK"); // adopted: the ordinary path
|
||||
checkEq(byId("pLink").textContent, "linked",
|
||||
"the adopted controller reads its own busy as linked, not as a rival");
|
||||
|
||||
// A refused upgrade: the board parks this socket as a pending claim -- `OK`, then
|
||||
// the E6-and-drop that answers the ordinary `H` on a claim slot -- and the page
|
||||
// must say "held", offer the button, and stop, instead of retrying every 2 s.
|
||||
sandbox.location.protocol = "https:";
|
||||
byId("token").value = "hunter2";
|
||||
FakeWebSocket.instances.length = 0;
|
||||
sentFrames.length = 0;
|
||||
x.authed = false; x.armed = false;
|
||||
x.connect();
|
||||
await wait(5);
|
||||
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
inst.onmessage({ data: "OK" }); // authenticated -- as a claim
|
||||
const afterConnect = sentFrames.length; // T + H from the ordinary flow
|
||||
inst.onmessage({ data: "E6:a pending claim may not send that" });
|
||||
inst.onclose(); // and the claim is dropped
|
||||
checkEq(byId("pLink").textContent, "held by another controller",
|
||||
"the un-adopted socket reads the board's silence as 'held'");
|
||||
checkEq(byId("takeover").hidden, false,
|
||||
"and the take-over button appears -- only now that the board said held");
|
||||
checkEq(sentFrames.length, afterConnect,
|
||||
"after the refusal the page sends nothing on its own -- no retry, no X");
|
||||
checkEq(x.autoReconnect, false,
|
||||
"the reconnect loop is off: re-claiming automatically is a fight");
|
||||
checkEq(FakeWebSocket.instances.length, 1, "and no second socket was opened");
|
||||
|
||||
// The one deliberate click: open, authenticate, ask for the bridge. T then X,
|
||||
// in that order, and nothing else -- no arm, no state, not even an H.
|
||||
byId("takeover").fire("click");
|
||||
await wait(5);
|
||||
checkEq(FakeWebSocket.instances.length, 2, "one click opened one takeover socket");
|
||||
const t = FakeWebSocket.instances[1];
|
||||
checkEq(sentFrames.slice(afterConnect).join(","), "Thunter2,X",
|
||||
"the click sends T then X, in that order, and nothing else");
|
||||
checkEq(byId("takeover").hidden, true, "and the button retires once the act is sent");
|
||||
t.onmessage({ data: "OK" }); // adopted: OK, then the status
|
||||
t.onmessage({ data: 'S{"armed":0,"usb":1,"typing":0,"busy":1}' });
|
||||
checkEq(x.armed, false,
|
||||
"the newcomer behaves like any controller, starting DISARMED");
|
||||
checkEq(byId("pLink").textContent, "linked", "and it holds the bridge now");
|
||||
const tlog = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(tlog.includes("bridge taken over") && tlog.includes("DISARMED"),
|
||||
"the log names what happened, including the state it landed in");
|
||||
|
||||
// E3 keeps its meaning next to E12's: "you were refused -- no room for a claim",
|
||||
// not "you were dropped". It still disarms a page that asks; it does not claim
|
||||
// this session was displaced. (Checked BEFORE the E12 walk, so "not a takeover"
|
||||
// means E3 did not set it, not that an earlier takeover was already on record.)
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.onFrame("E3:no room");
|
||||
checkEq(x.armed, false, "E3 still refuses the ask and disarms");
|
||||
checkEq(x.takenOver, false, "but E3 is not a takeover -- nothing displaced anyone");
|
||||
const e3log = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(e3log.includes("no room for a claim"),
|
||||
"and its log line says 'no room', the sentence that distinguishes it from E12");
|
||||
|
||||
// The incumbent's side, which this page now is: E12 disarms, releases the mouse,
|
||||
// ends a typing run mid-flight, marks the pill, and keeps the page down -- the
|
||||
// same gesture the board itself ran through the funnel.
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "set-up: this session holds the mouse");
|
||||
byId("paste").value = "run".repeat(100); // three frames' worth
|
||||
sentFrames.length = 0;
|
||||
p = x.sendPaste();
|
||||
await wait(5);
|
||||
x.onFrame("E12:session taken over");
|
||||
checkEq(x.armed, false, "E12 disarms the incumbent immediately");
|
||||
checkEq(x.mCaptured, false, "and drops the mouse");
|
||||
x.onFrame('S{"armed":0,"usb":1,"typing":0}'); // release the run's waiter
|
||||
await p;
|
||||
checkEq(x.typing, false, "and ends the typing run");
|
||||
checkEq(frames().length, 1, "the run stops -- no further P= frames after a takeover");
|
||||
checkEq(byId("pLink").textContent, "taken over", "and the pill says what happened");
|
||||
const ilog = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(ilog.includes("another controller took over"),
|
||||
"and the log carries the reason, in the incumbent's own words");
|
||||
checkEq(x.autoReconnect, false, "and this page stays down until someone clicks");
|
||||
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
const nSockets = FakeWebSocket.instances.length;
|
||||
inst.onclose();
|
||||
checkEq(FakeWebSocket.instances.length, nSockets,
|
||||
"the close after E12 opens nothing -- no auto-reclaim");
|
||||
checkEq(byId("pLink").textContent, "taken over", "the pill keeps the fact");
|
||||
checkEq(byId("takeover").hidden, false,
|
||||
"coming back is one deliberate click away, never zero");
|
||||
x.setArmed(false, "harness: done");
|
||||
|
||||
console.log("element ids");
|
||||
const markup = served;
|
||||
const present = new Set();
|
||||
|
||||
+53
-5
@@ -45,9 +45,24 @@ int main() {
|
||||
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");
|
||||
@@ -56,6 +71,9 @@ int main() {
|
||||
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);
|
||||
@@ -464,6 +482,11 @@ int main() {
|
||||
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");
|
||||
@@ -549,15 +572,35 @@ int main() {
|
||||
char json[kr::kStatusJsonCap];
|
||||
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
|
||||
// A default snapshot: no TLS, and no label for an identity that is not there.
|
||||
// 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.
|
||||
@@ -612,10 +655,11 @@ int main() {
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -637,6 +681,8 @@ int main() {
|
||||
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];
|
||||
@@ -650,6 +696,8 @@ int main() {
|
||||
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.
|
||||
|
||||
+199
-10
@@ -382,6 +382,84 @@ check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
|
||||
"closeLink() treats anything that reached the WebSocket phase as able to leave "
|
||||
"keys down, not only what is still marked authenticated")
|
||||
|
||||
print("the bridge handover (phase 03)")
|
||||
# `X` moves the keyboard from one socket to the other, and the safety sentences about it
|
||||
# are ordering facts in control_server.cpp that no host-runnable test can observe -- they
|
||||
# need two real sockets and a board. Same trade as bug 10's pins above: these are source
|
||||
# pins, deliberately, for the three sentences a later edit can undo silently.
|
||||
server_hdr = (root / "src" / "control_server.h").read_text()
|
||||
claim_src = (root / "src" / "claim.h").read_text()
|
||||
|
||||
hand_at = server_src.index("void ControlServer::handOverClaim")
|
||||
hand = server_src[hand_at:server_src.index("\n}", hand_at)]
|
||||
# Code lines only -- the function's comment is full of the words it forbids itself, and
|
||||
# a check that flagged the comment would be worse than no check at all.
|
||||
hand_code = "\n".join(l for l in hand.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
|
||||
# (a) The funnel runs BEFORE the newcomer is adopted. closeLink() is the release funnel
|
||||
# (it disarms and releases for anything that reached the WebSocket phase -- bug 10's
|
||||
# fix -- and runs _onLinkChange(false), which in main.cpp is disarm("controller
|
||||
# disconnected") -> releaseEverything()); adopting the claimant is `_authed = true`.
|
||||
# If those two ever swap order, the newcomer inherits a held key and an armed
|
||||
# bridge, and nothing on a host would ever notice.
|
||||
check("closeLink(\"taken over\")" in hand_code,
|
||||
"the handover runs the existing release funnel, not a copy of it")
|
||||
check("_authed = true" in hand_code,
|
||||
"and it is the code that adopts the claimant as the controller")
|
||||
check(hand_code.index("closeLink(\"taken over\")") < hand_code.index("_authed = true"),
|
||||
"the funnel runs BEFORE the claimant is adopted -- a takeover can never inherit "
|
||||
"a held key or an armed bridge")
|
||||
check("if (!linked) disarm(\"controller disconnected\");" in main_src,
|
||||
"and the funnel's link change is the one main.cpp disarms (releases every key "
|
||||
"and every mouse button) on")
|
||||
|
||||
# (b) No arm is reachable from the `X` handler. The board lands DISARMED by design; the
|
||||
# newcomer arms with an ordinary authenticated A1 or never. `arm(`/`setArmed`/"armed =
|
||||
# true" must not appear in the handover's code lines, nor in the controller-side `X`
|
||||
# case in main.cpp (which is a no-op answered OK).
|
||||
for tok in ("setArmed", "armed = true", "arm("):
|
||||
check(tok not in hand_code,
|
||||
f"the handover path contains no {tok!r} -- a takeover cannot produce an "
|
||||
f"armed board")
|
||||
mt_at = main_src.index("case proto::Cmd::Takeover:")
|
||||
mt_case_raw = main_src[mt_at:main_src.index("return;", mt_at)]
|
||||
# Comment lines stripped for the same reason as hand_code: the case's own comment
|
||||
# explains what it must not do, in words that contain the words being searched for.
|
||||
mt_case = "\n".join(l for l in mt_case_raw.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
check("control.sendOk();" in mt_case,
|
||||
"`X` from the incumbent controller is a no-op answered OK, not E6")
|
||||
check("setArmed" not in mt_case and "arm(" not in mt_case,
|
||||
"and it cannot arm either -- you already hold the bridge, that is all this says")
|
||||
|
||||
# (c) A claim cannot be promoted without `_claimAuthed`. Two halves: the whitelist is
|
||||
# consulted before the Takeover branch in handleClaimFrame, and claim.h admits `X`
|
||||
# only in the authenticated branch, so the ordering has something correct to check.
|
||||
hcf_at = server_src.index("bool ControlServer::handleClaimFrame")
|
||||
hcf = server_src[hcf_at:server_src.index("\n}", hcf_at)]
|
||||
check("claim::honours(_viewerRole, _claimAuthed, cmd)" in hcf,
|
||||
"handleClaimFrame asks the one whitelist, with _claimAuthed in the question")
|
||||
check(hcf.index("claim::honours(") < hcf.index("proto::Cmd::Takeover"),
|
||||
"and it asks BEFORE the Takeover branch -- `X` cannot reach the promotion "
|
||||
"unauthenticated")
|
||||
check_eq(server_src.count("handOverClaim(nowMs);"), 1,
|
||||
"handOverClaim is called from exactly that one place, behind the whitelist")
|
||||
hon_at = claim_src.index("inline bool honours(")
|
||||
hon = claim_src[hon_at:claim_src.index("\n}", hon_at)]
|
||||
check("if (!authed) return cmd == proto::Cmd::Auth;" in hon,
|
||||
"claim.h admits only T before authentication -- an unauthenticated `X` is refused")
|
||||
check(hon.index("if (!authed)") < hon.index("case proto::Cmd::Takeover"),
|
||||
"and `X` lives in the authenticated branch only, after that refusal")
|
||||
|
||||
# E12 is its own sentence: "you were dropped", not E3's "you were refused". The named
|
||||
# code and the incumbent-side send are both pinned so the two can never merge back.
|
||||
check("kErrTakenOver = 12" in server_hdr,
|
||||
"the error code is named at 12, the number the incumbent's page will read")
|
||||
check('sendError(kErrTakenOver, "session taken over")' in hand_code,
|
||||
"and it is that named code the incumbent is told with, before the funnel closes "
|
||||
"its link")
|
||||
|
||||
print("one routing path, two transports")
|
||||
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
|
||||
# claims are structural, so they are pinned as source sentences: what they defend is a
|
||||
@@ -401,11 +479,16 @@ check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in
|
||||
"readSome() stamps the silence clock only for an authenticated controller on the "
|
||||
"WebSocket, whatever transport the bytes came over")
|
||||
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
|
||||
# ... and it is the ONLY inbound path that may. Three writers of that clock would mean a
|
||||
# route that can keep the board armed: readSome, and closeLink stamping on the way out.
|
||||
# ... and it is the ONLY inbound path that may. Every writer of that clock is a route
|
||||
# that can keep the board armed, so the count is pinned: readSome, the authentication,
|
||||
# closeLink stamping on the way out, and the bridge handover ADOPTING an already-
|
||||
# authenticated claim (phase 03 -- the claim was checked against the same passphrase,
|
||||
# so from that instant a controller exists and is speaking; the adoption stamps exactly
|
||||
# the stamp an auth would). A fifth writer needs its sentence written here first.
|
||||
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
|
||||
check_eq(len(stamps), 3,
|
||||
"exactly three writers of the controller-silence clock: auth, readSome, closeLink")
|
||||
check_eq(len(stamps), 4,
|
||||
"exactly four writers of the controller-silence clock: auth, readSome, "
|
||||
"closeLink, handover adoption")
|
||||
check(any("the controller exists from here" in l for l in stamps),
|
||||
"one of them is the authentication itself")
|
||||
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
|
||||
@@ -590,17 +673,33 @@ for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
|
||||
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
|
||||
|
||||
|
||||
# The status an adopted controller gets back to back with its `OK` -- the frame
|
||||
# whose ABSENCE after `OK` is how a pending claim learns the board is busy. The
|
||||
# values are the honest post-handover ones: disarmed, busy (with itself), one
|
||||
# completed takeover.
|
||||
FAKE_STATUS = b'S{"v":"1.0.0","armed":0,"usb":1,"typing":0,"busy":1,"tkov":1}'
|
||||
|
||||
|
||||
class FakeBoardSock:
|
||||
"""Records every byte this side sends; answers the upgrade and `T<token>`.
|
||||
|
||||
The recorded frame list is the point: "the pin aborted before T<token>" can
|
||||
only be shown by what did NOT go out.
|
||||
|
||||
`claim_slot=True` makes the board a BUSY one: `T` authenticates into the spare
|
||||
slot as a pending claim (`OK`, and deliberately no adoption status -- the status
|
||||
frame belongs to the controller alone), and the board adopts the client only
|
||||
when it sends the one deliberate `X`. That asymmetry is the whole of phase 03
|
||||
from this side of the socket, so the fake spells it out instead of collapsing
|
||||
both answers into "OK".
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
|
||||
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False,
|
||||
claim_slot=False):
|
||||
self.host, self.port = host, port
|
||||
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
|
||||
self.flood = flood # answer with headers that never terminate
|
||||
self.claim_slot = claim_slot # busy board: park the newcomer as a claim
|
||||
self.sent = [] # every sendall payload, in order
|
||||
self.inbox = bytearray()
|
||||
self.upgraded = False
|
||||
@@ -639,6 +738,20 @@ class FakeBoardSock:
|
||||
del self.post[:6 + length]
|
||||
if payload.startswith(b"T"):
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
||||
if not self.claim_slot:
|
||||
# The adoption status a real controller gets back to back with
|
||||
# the OK -- the auth path sends both on one write.
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
elif payload == b"X":
|
||||
# Only a claim has anything to take; this fake only ever sees X
|
||||
# from one. The handover lands disarmed, and the adopted session
|
||||
# gets exactly the greeting a fresh authentication gets.
|
||||
if self.claim_slot:
|
||||
self.claim_slot = False
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
elif payload == b"H":
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
|
||||
def recv(self, n):
|
||||
if self.inbox:
|
||||
@@ -706,14 +819,15 @@ class FakeSsl:
|
||||
|
||||
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
||||
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
|
||||
empty_der=False, bad_accept=False, flood=False,
|
||||
empty_der=False, bad_accept=False, flood=False, claim_slot=False,
|
||||
take_over=False,
|
||||
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
|
||||
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
|
||||
socks = []
|
||||
|
||||
def fake_create(addr, timeout):
|
||||
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
|
||||
bad_accept=bad_accept, flood=flood)
|
||||
bad_accept=bad_accept, flood=flood, claim_slot=claim_slot)
|
||||
socks.append(s)
|
||||
return s
|
||||
|
||||
@@ -725,7 +839,8 @@ def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
|
||||
fingerprint=fingerprint, ca=ca,
|
||||
insecure=insecure, plain=plain)
|
||||
insecure=insecure, plain=plain,
|
||||
take_over=take_over)
|
||||
except relayctl.RelayError as exc:
|
||||
err = exc
|
||||
finally:
|
||||
@@ -835,10 +950,47 @@ frames = client_frame_payloads(socks[0])
|
||||
check(frames and frames[0] == b"Tsekrit",
|
||||
"the rollback session authenticates the same way")
|
||||
|
||||
print("the busy board and --take-over")
|
||||
# Phase 03, from the terminal side. A busy board authenticates this tool into its
|
||||
# one spare slot as a pending claim -- `OK` and then the silence that means `busy`,
|
||||
# because the status frame belongs to the controller alone. The default is a
|
||||
# refusal that names the flag; the flag is one deliberate `X`; a free board is a
|
||||
# no-op. The frames that DO go out are asserted, in order, because "takeover can
|
||||
# never arm" is a sentence about what a session looks like at the moment it takes
|
||||
# over: T, X, and nothing that types.
|
||||
|
||||
# Default against a busy board: refuse, name --take-over, send no X, free the slot.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True)
|
||||
check(isinstance(err, relayctl.RelayError), "a busy board refuses by default")
|
||||
check(err is not None and "--take-over" in str(err), "and the refusal names the flag")
|
||||
check(err is not None and "DISARMED" in str(err),
|
||||
"and says what the handover lands on, before anyone takes it")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
||||
"the refusal sends no X -- the act needs the flag, not just the passphrase")
|
||||
check(socks and socks[0].closed, "and the claim slot is freed rather than parked on")
|
||||
|
||||
# With the flag: the claim's one deliberate frame, in the right order, alone.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True,
|
||||
take_over=True)
|
||||
check(err is None and relay is not None, f"--take-over takes a busy board ({err})")
|
||||
check(relay is not None and relay.took_over, "and the session knows it took the bridge")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit", b"X"],
|
||||
"T then X, in that order, and nothing else -- one intent, no arm, no state")
|
||||
|
||||
# --take-over against a free board is a no-op, not an error, and never wastes an X
|
||||
# on a board that has nothing to take.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, take_over=True)
|
||||
check(err is None and relay is not None, f"--take-over on a free board succeeds ({err})")
|
||||
check(relay is not None and relay.busy is False and not relay.took_over,
|
||||
"and it reports the board was free")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
||||
"a free board is never sent an X it did not need")
|
||||
|
||||
# --- main(): the refusals that need no board -----------------------------------
|
||||
|
||||
|
||||
def run_main(argv, fp_env=None, token_env="hunter2secret"):
|
||||
def run_main(argv, fp_env=None, token_env="hunter2secret", claim_slot=False,
|
||||
socks_out=None):
|
||||
"""Run main() with the transport faked: create_connection records the address
|
||||
instead of dialing, and ssl is the fake from above. Any credential that
|
||||
reaches the returned text is a test failure, so the caller never has to
|
||||
@@ -847,7 +999,10 @@ def run_main(argv, fp_env=None, token_env="hunter2secret"):
|
||||
|
||||
def record_create(addr, timeout):
|
||||
calls.append(addr)
|
||||
return FakeBoardSock(addr[0], addr[1])
|
||||
s = FakeBoardSock(addr[0], addr[1], claim_slot=claim_slot)
|
||||
if socks_out is not None:
|
||||
socks_out.append(s)
|
||||
return s
|
||||
|
||||
old_argv = sys.argv
|
||||
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
|
||||
@@ -919,6 +1074,40 @@ code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
|
||||
check_eq(code, 4, "paste still needs --yes before any connection")
|
||||
check(calls == [], "and that check still does not require a reachable board")
|
||||
|
||||
# The whole takeover gesture through main(): take the held bridge, then read the
|
||||
# board. T, X, H -- claim, handover, status -- with the terminal told which path
|
||||
# ran, and no credential in anything it prints.
|
||||
board = []
|
||||
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
||||
token_env="sekrit", claim_slot=True,
|
||||
socks_out=board)
|
||||
check_eq(code, 0, f"--take-over status against a busy board runs ({err_text})")
|
||||
session = client_frame_payloads(board[0]) if board else []
|
||||
check_eq([f for f in session if f in (b"Tsekrit", b"X", b"H")],
|
||||
[b"Tsekrit", b"X", b"H"],
|
||||
"the busy session's frames are T, then X, then H -- claim, handover, status")
|
||||
check("took the bridge" in out and "DISARMED" in out,
|
||||
"and the terminal is told what the handover did before anything else runs")
|
||||
check("sekrit" not in out and "sekrit" not in err_text,
|
||||
"and the passphrase stays out of the terminal")
|
||||
|
||||
board = []
|
||||
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
||||
token_env="sekrit", socks_out=board)
|
||||
check_eq(code, 0, f"--take-over against a free board still runs ({err_text})")
|
||||
check("nothing to take" in out, "and says it was a no-op, in those words")
|
||||
check_eq([f for f in (client_frame_payloads(board[0]) if board else []) if f == b"X"],
|
||||
[], "with no X on the wire at all")
|
||||
|
||||
# The default against a busy board, through main(): exit 1, the refusal names
|
||||
# --take-over, and the wire never carried an X.
|
||||
code, out, err_text, calls = run_main(["--plain", "status"], token_env="sekrit",
|
||||
claim_slot=True, socks_out=board)
|
||||
check_eq(code, 1, "a busy board is a refusal through main() too")
|
||||
check("--take-over" in err_text, "and the refusal names the flag in the terminal")
|
||||
check_eq([f for f in (client_frame_payloads(board[-1]) if board else [])
|
||||
if f == b"X"], [], "and refused means refused: no X went out anyway")
|
||||
|
||||
print("the target-side verifier")
|
||||
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
|
||||
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
|
||||
|
||||
Reference in New Issue
Block a user