authorclover caruso <git@paperclover.net> 2026-10-15 08:10:05-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 08:34:53-07:00
log0641110e7a154fd20595e7fd290ef572c8a36235
tree09d6eeab9cf54d9281cbdff1fe9fec290171b022
parentf2e89c094937c86ace1a3078d7d48d6056a5d933
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(live): a burned code's number is not given out again at once

Once a burned code's owner left, the relay forgot the room, so the host's replacement code could draw the same number from the thousand on offer. The old code then led to the new one: a guest typing it was told its code was wrong rather than expired, and spent a try of the new code. A burned room now outlives its members by a minute, during which its number is neither claimed nor joined, and anyone typing the old code hears it expired. The relay test checks this by ending a polled owner, which answers only once it has left, so it needs no waiting. Refs #150 Assisted-by: claude-opus-5.5

3 files changed, 85 insertions(+), 16 deletions(-)

crates/notebook/tests/live_share.rs+3-4
...@@ -424,11 +424,10 @@ fn wrong_codes_are_refused_and_counted() {...@@ -424,11 +424,10 @@ fn wrong_codes_are_refused_and_counted() {
424 .is_some_and(|now| now != code && share::code(&now).is_some())424 .is_some_and(|now| now != code && share::code(&now).is_some())
425 });425 });
426 let fresh = host.code().unwrap();426 let fresh = host.code().unwrap();
427 assert!(matches!(427 assert_eq!(join(&code).unwrap_err(), Refusal::Expired);
428 join(&code).unwrap_err(),428 let burned = number;
429 Refusal::Expired | Refusal::NoOne
430 ));
431 let (number, secret) = notebook::live::code::parse(&fresh).unwrap();429 let (number, secret) = notebook::live::code::parse(&fresh).unwrap();
430 assert_ne!(number, burned);
432 let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap();431 let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap();
433 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);432 assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong);
434 // A third wrong code in a minute locks this network out, even from the right code.433 // A third wrong code in a minute locks this network out, even from the right code.
crates/relay/src/server.rs+26-12
...@@ -151,7 +151,9 @@ struct Room {...@@ -151,7 +151,9 @@ struct Room {
151 joins: Bucket,151 joins: Bucket,
152 bytes: Bucket,152 bytes: Bucket,
153 failures: u32,153 failures: u32,
154 burned: bool,154 /// When wrong codes burned it, then when it emptied: its number stays out of use for a
155 /// minute after, so an old code never leads to its owner's next one.
156 burned: Option<Instant>,
155}157}
156158
157struct Member {159struct Member {
...@@ -429,7 +431,7 @@ impl Relay {...@@ -429,7 +431,7 @@ impl Relay {
429 let free = |number: u32| {431 let free = |number: u32| {
430 rooms432 rooms
431 .get(&code(number))433 .get(&code(number))
432 .is_none_or(|room| room.owner.is_none() && !room.burned)434 .is_none_or(|room| room.owner.is_none() && room.burned.is_none())
433 };435 };
434 let number = back436 let number = back
435 .filter(|number| NAMEPLATES.contains(number) && free(*number))437 .filter(|number| NAMEPLATES.contains(number) && free(*number))
...@@ -447,13 +449,13 @@ impl Relay {...@@ -447,13 +449,13 @@ impl Relay {
447 (code(number), Some(number))449 (code(number), Some(number))
448 }450 }
449 Ask::Room(tag) if tag.starts_with("code-") => {451 Ask::Room(tag) if tag.starts_with("code-") => {
450 let room = rooms452 let room = rooms.get(&tag).ok_or(Refusal::NotFound)?;
451 .get(&tag)453 if room.burned.is_some() {
452 .filter(|room| room.owner.is_some())
453 .ok_or(Refusal::NotFound)?;
454 if room.burned {
455 return Err(Refusal::Gone);454 return Err(Refusal::Gone);
456 }455 }
456 if room.owner.is_none() {
457 return Err(Refusal::NotFound);
458 }
457 if client.failures.len() + client.pending >= config.failures_per_minute as usize {459 if client.failures.len() + client.pending >= config.failures_per_minute as usize {
458 let wait = client.failures.front().map_or(config.pending, |first| {460 let wait = client.failures.front().map_or(config.pending, |first| {
459 (*first + MINUTE).saturating_duration_since(now)461 (*first + MINUTE).saturating_duration_since(now)
...@@ -474,7 +476,7 @@ impl Relay {...@@ -474,7 +476,7 @@ impl Relay {
474 joins: Bucket::full(f64::from(config.room_joins_per_minute), now),476 joins: Bucket::full(f64::from(config.room_joins_per_minute), now),
475 bytes: Bucket::full(config.room_bytes_per_second as f64, now),477 bytes: Bucket::full(config.room_bytes_per_second as f64, now),
476 failures: 0,478 failures: 0,
477 burned: false,479 burned: None,
478 });480 });
479 let per_minute = f64::from(config.room_joins_per_minute);481 let per_minute = f64::from(config.room_joins_per_minute);
480 let admitted = room.members.len() < config.max_room_peers;482 let admitted = room.members.len() < config.max_room_peers;
...@@ -649,7 +651,8 @@ impl Relay {...@@ -649,7 +651,8 @@ impl Relay {
649 }651 }
650 }652 }
651653
652 /// Fails the peers that waited too long to meet a code's owner, and forgets quiet addresses.654 /// Fails the peers that waited too long to meet a code's owner, and forgets burned rooms
655 /// empty a minute and quiet addresses.
653 fn sweep(&self, now: Instant) {656 fn sweep(&self, now: Instant) {
654 let config = &self.config;657 let config = &self.config;
655 let mut state = self.state.lock().unwrap();658 let mut state = self.state.lock().unwrap();
...@@ -676,6 +679,12 @@ impl Relay {...@@ -676,6 +679,12 @@ impl Relay {
676 depart(&mut state, config, &poll.tag, poll.slot, now);679 depart(&mut state, config, &poll.tag, poll.slot, now);
677 }680 }
678 }681 }
682 state.rooms.retain(|_, room| {
683 !room.members.is_empty()
684 || room
685 .burned
686 .is_some_and(|since| now.saturating_duration_since(since) < MINUTE)
687 });
679 state.addresses.retain(|_, address| {688 state.addresses.retain(|_, address| {
680 address.refresh(now);689 address.refresh(now);
681 !address.quiet(config, now)690 !address.quiet(config, now)
...@@ -714,8 +723,8 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant...@@ -714,8 +723,8 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant
714 }723 }
715 if member.pending.is_some() {724 if member.pending.is_some() {
716 room.failures += 1;725 room.failures += 1;
717 if room.failures >= config.burn_after && !room.burned {726 if room.failures >= config.burn_after && room.burned.is_none() {
718 room.burned = true;727 room.burned = Some(now);
719 eprintln!("{tag}: burned after {} wrong codes", room.failures);728 eprintln!("{tag}: burned after {} wrong codes", room.failures);
720 if let Some(owner) = room.owner.and_then(|owner| room.members.get(&owner)) {729 if let Some(owner) = room.owner.and_then(|owner| room.members.get(&owner)) {
721 owner.outbox.push(notice(Notice::Burned));730 owner.outbox.push(notice(Notice::Burned));
...@@ -734,7 +743,12 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant...@@ -734,7 +743,12 @@ fn depart(state: &mut State, config: &Config, tag: &str, slot: u32, now: Instant
734 }743 }
735 }744 }
736 if room.members.is_empty() {745 if room.members.is_empty() {
737 rooms.remove(tag);746 match &mut room.burned {
747 Some(since) => *since = now,
748 None => {
749 rooms.remove(tag);
750 }
751 }
738 }752 }
739}753}
740754
crates/relay/tests/relay.rs+56
...@@ -40,6 +40,27 @@ impl Relay {...@@ -40,6 +40,27 @@ impl Relay {
40 response40 response
41 }41 }
4242
43 /// Asks `method` on `path` with `body` on a connection of its own: the status line and
44 /// the answer.
45 fn ask(&self, method: &str, path: &str, body: &[u8]) -> (String, Vec<u8>) {
46 let mut stream = TcpStream::connect(self.address).unwrap();
47 write!(
48 stream,
49 "{method} {path} HTTP/1.1\r\nHost: relay\r\nContent-Length: {}\r\n\r\n",
50 body.len()
51 )
52 .unwrap();
53 stream.write_all(body).unwrap();
54 let head = ws::head(&mut stream).unwrap();
55 let length = ws::header(&head, "Content-Length")
56 .unwrap()
57 .parse()
58 .unwrap();
59 let mut answer = vec![0; length];
60 stream.read_exact(&mut answer).unwrap();
61 (head.lines().next().unwrap().to_owned(), answer)
62 }
63
43 fn join(&self, path: &str) -> Result<Peer, String> {64 fn join(&self, path: &str) -> Result<Peer, String> {
44 self.join_from(path, None)65 self.join_from(path, None)
45 }66 }
...@@ -299,6 +320,41 @@ fn wrong_codes_burn_a_code() {...@@ -299,6 +320,41 @@ fn wrong_codes_burn_a_code() {
299 assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone");320 assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone");
300}321}
301322
323/// A burned code's number stays out of use once its owner leaves, so the old code can't lead
324/// to the owner's next one.
325#[test]
326fn a_burned_number_is_not_given_out_again() {
327 let relay = Relay::start(&["--burn-after", "1"]);
328 // A polled owner, whose leaving is answered once it has left.
329 let (_, session) = relay.ask("GET", "/v1/claim?poll=1", b"");
330 let session = String::from_utf8(session).unwrap();
331 let session = format!("/v1/poll/{}", session.strip_prefix("session ").unwrap());
332 let say = |opcode: u8, text: &str| {
333 let frame = ws::frame(opcode, text.as_bytes(), Some([1, 2, 3, 4]));
334 relay.ask("POST", &session, &frame).0
335 };
336 let (_, heard) = relay.ask("GET", &session, b"");
337 let Ok(Message::Text(nameplate)) = ws::Reader::new(&heard[..], 1 << 20, false).read() else {
338 panic!("heard {heard:?}");
339 };
340 let number: u32 = nameplate
341 .strip_prefix("nameplate ")
342 .unwrap()
343 .parse()
344 .unwrap();
345 let path = format!("/v1/room/code-{number}");
346 let mut guess = relay.join(&path).unwrap();
347 assert_eq!(guess.text(), "welcome 2 1");
348 assert_eq!(say(ws::TEXT, "failed 2"), "HTTP/1.1 200 OK");
349 assert!(guess.closed(Duration::from_secs(5)));
350 assert_eq!(say(ws::CLOSE, ""), "HTTP/1.1 410 Gone");
351 let mut next = relay
352 .join(&format!("/v1/claim?nameplate={number}"))
353 .unwrap();
354 assert_ne!(next.text(), format!("nameplate {number}"));
355 assert_eq!(status(relay.join(&path)), "HTTP/1.1 410 Gone");
356}
357
302/// An owner coming back asks for its code's number again, and gets it while no one else358/// An owner coming back asks for its code's number again, and gets it while no one else
303/// has claimed it.359/// has claimed it.
304#[test]360#[test]