1use std::collections::{HashMap, VecDeque};
29use std::fmt;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::{Mutex, MutexGuard};
32
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35use tokio::sync::mpsc;
36
37pub const USER_SESSION_KEY_PREFIX: &str = "copilot-u-";
40pub const INVESTIGATE_SESSION_KEY: &str = "copilot-investigate";
45pub const MAX_USER_ID_CHARS: usize = 48;
47pub const USER_KEY_ID_CHARS: usize = 20;
50pub const USER_KEY_HASH_HEX: usize = 32;
54const MAX_LANE_KEY_CHARS: usize = 64;
56
57const _: () = assert!(
59 USER_SESSION_KEY_PREFIX.len() + USER_KEY_ID_CHARS + 1 + USER_KEY_HASH_HEX <= MAX_LANE_KEY_CHARS
60);
61const _: () = assert!(USER_KEY_HASH_HEX.is_multiple_of(2) && USER_KEY_HASH_HEX <= 64);
62pub(crate) const WRITER_BUFFER: usize = 256;
64pub(crate) const MAX_REQUEUES: u8 = 10;
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct LaneIds {
72 pub base: String,
74 pub investigate: String,
76 pub user_prefix: String,
78}
79
80impl LaneIds {
81 pub fn new(base: &str) -> Self {
83 Self {
84 base: base.to_string(),
85 investigate: format!("{base}:investigate"),
86 user_prefix: format!("{base}:u:"),
87 }
88 }
89
90 pub fn user_lane(&self, user_id: &str) -> String {
92 format!("{}{user_id}", self.user_prefix)
93 }
94
95 pub fn is_user_lane(&self, jid: &str) -> bool {
97 jid.len() > self.user_prefix.len() && jid.starts_with(&self.user_prefix)
98 }
99
100 pub fn owns(&self, jid: &str) -> bool {
102 jid == self.base
103 || (jid.len() > self.base.len()
104 && jid.starts_with(&self.base)
105 && jid.as_bytes()[self.base.len()] == b':')
106 }
107}
108
109pub fn lane_jid_for_user(user_id: &str) -> String {
112 LaneIds::new(crate::config::DEFAULT_BASE_JID).user_lane(user_id)
113}
114
115pub fn is_valid_user_id(id: &str) -> bool {
118 !id.is_empty()
119 && id.len() <= MAX_USER_ID_CHARS
120 && id
121 .bytes()
122 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
129#[error("user_id must be a string or integer matching ^[A-Za-z0-9_-]{{1,48}}$")]
130pub struct InvalidUserId;
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash)]
138pub struct UserId(String);
139
140impl UserId {
141 pub fn parse(raw: &str) -> Result<Self, InvalidUserId> {
143 if is_valid_user_id(raw) {
144 Ok(Self(raw.to_string()))
145 } else {
146 Err(InvalidUserId)
147 }
148 }
149
150 pub fn from_json(raw: Option<&Value>) -> Result<Option<Self>, InvalidUserId> {
154 match raw {
155 None | Some(Value::Null) => Ok(None),
156 Some(Value::String(s)) if s.is_empty() => Ok(None),
157 Some(Value::String(s)) => Self::parse(s).map(Some),
158 Some(Value::Number(n)) if n.is_i64() || n.is_u64() => {
159 Self::parse(&n.to_string()).map(Some)
160 }
161 Some(_) => Err(InvalidUserId),
162 }
163 }
164
165 pub fn as_str(&self) -> &str {
167 &self.0
168 }
169
170 pub fn session_key(&self) -> String {
180 let prefix: String = self
181 .0
182 .chars()
183 .take(USER_KEY_ID_CHARS)
184 .map(|c| c.to_ascii_lowercase())
185 .collect();
186 let digest = Sha256::digest(self.0.as_bytes());
187 let hash: String = digest
188 .iter()
189 .take(USER_KEY_HASH_HEX / 2)
190 .map(|b| format!("{b:02x}"))
191 .collect();
192 format!("{USER_SESSION_KEY_PREFIX}{prefix}-{hash}")
193 }
194}
195
196impl fmt::Display for UserId {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 f.write_str(&self.0)
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub(crate) enum SseFrame {
205 Text(String),
207 Done,
209}
210
211impl SseFrame {
212 pub(crate) fn to_json(&self) -> String {
215 match self {
216 SseFrame::Text(content) => format!(
217 r#"{{"type":"text","content":{}}}"#,
218 Value::String(content.clone())
219 ),
220 SseFrame::Done => r#"{"type":"done"}"#.to_string(),
221 }
222 }
223}
224
225struct Writer {
226 id: u64,
227 tx: mpsc::Sender<SseFrame>,
228 answered: bool,
230 requeues: u8,
232}
233
234#[derive(Default)]
235struct LaneStreams {
236 pending: VecDeque<Writer>,
237 active: Option<Writer>,
238 owed: usize,
241 in_turn: bool,
243}
244
245impl LaneStreams {
246 fn writer_count(&self) -> usize {
247 self.pending.len() + usize::from(self.active.is_some())
248 }
249
250 fn is_idle(&self) -> bool {
251 self.writer_count() == 0 && !self.in_turn && self.owed == 0
252 }
253
254 fn clamp_owed(&mut self) {
255 self.owed = self.owed.min(self.pending.len());
256 }
257}
258
259#[derive(Default)]
260struct RegistryInner {
261 lanes: HashMap<String, LaneStreams>,
262 total_writers: usize,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub(crate) enum OpenError {
268 LaneBusy,
270 TooManyStreams,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub(crate) enum SendOutcome {
277 Delivered,
279 NoWriter,
281 Overflow,
283 ClientGone,
285}
286
287#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
289pub(crate) struct StreamCounts {
290 pub pending: usize,
291 pub active: usize,
292 pub lanes_in_turn: usize,
293}
294
295#[derive(Default)]
298pub(crate) struct StreamRegistry {
299 inner: Mutex<RegistryInner>,
300 next_id: AtomicU64,
301}
302
303impl StreamRegistry {
304 fn lock(&self) -> MutexGuard<'_, RegistryInner> {
305 self.inner.lock().unwrap_or_else(|e| e.into_inner())
306 }
307
308 pub(crate) fn open(
310 &self,
311 jid: &str,
312 max_per_lane: usize,
313 max_total: usize,
314 ) -> Result<(u64, mpsc::Receiver<SseFrame>), OpenError> {
315 let mut inner = self.lock();
316 if inner.total_writers >= max_total {
317 return Err(OpenError::TooManyStreams);
318 }
319 let lane = inner.lanes.entry(jid.to_string()).or_default();
320 if lane.writer_count() >= max_per_lane {
321 return Err(OpenError::LaneBusy);
322 }
323 let id = self.next_id.fetch_add(1, Ordering::Relaxed);
324 let (tx, rx) = mpsc::channel(WRITER_BUFFER);
325 lane.pending.push_back(Writer {
326 id,
327 tx,
328 answered: false,
329 requeues: 0,
330 });
331 inner.total_writers += 1;
332 Ok((id, rx))
333 }
334
335 pub(crate) fn remove(&self, jid: &str, id: u64) {
337 let mut inner = self.lock();
338 let mut removed = 0;
339 if let Some(lane) = inner.lanes.get_mut(jid) {
340 let before = lane.pending.len();
341 lane.pending.retain(|w| w.id != id);
342 removed += before - lane.pending.len();
343 if lane.active.as_ref().is_some_and(|w| w.id == id) {
344 lane.active = None;
345 removed += 1;
346 }
347 lane.clamp_owed();
348 if lane.is_idle() {
349 inner.lanes.remove(jid);
350 }
351 }
352 inner.total_writers = inner.total_writers.saturating_sub(removed);
353 }
354
355 pub(crate) fn turn_started(&self, jid: &str) -> bool {
358 let mut inner = self.lock();
359 let lane = inner.lanes.entry(jid.to_string()).or_default();
360 lane.in_turn = true;
361 if lane.active.is_some() {
362 if lane.owed < lane.pending.len() {
363 lane.owed += 1;
364 }
365 return false;
366 }
367 match lane.pending.pop_front() {
368 Some(w) => {
369 lane.active = Some(w);
370 lane.clamp_owed();
371 true
372 }
373 None => false,
374 }
375 }
376
377 pub(crate) fn turn_complete(&self, jid: &str) -> bool {
381 let mut inner = self.lock();
382 let mut closed = false;
383 if let Some(lane) = inner.lanes.get_mut(jid) {
384 if let Some(w) = lane.active.take() {
385 let _ = w.tx.try_send(SseFrame::Done);
386 closed = true;
387 }
388 if lane.owed > 0 && !lane.pending.is_empty() {
389 lane.owed -= 1;
390 lane.active = lane.pending.pop_front();
391 } else {
392 lane.owed = 0;
393 lane.in_turn = false;
394 }
395 if lane.is_idle() {
396 inner.lanes.remove(jid);
397 }
398 }
399 if closed {
400 inner.total_writers = inner.total_writers.saturating_sub(1);
401 }
402 closed
403 }
404
405 pub(crate) fn turn_ended(&self, jid: &str) -> bool {
410 let mut inner = self.lock();
411 let mut closed = false;
412 if let Some(lane) = inner.lanes.get_mut(jid) {
413 if let Some(mut w) = lane.active.take() {
414 if !w.answered && w.requeues < MAX_REQUEUES {
415 w.requeues += 1;
416 lane.pending.push_front(w);
417 } else {
418 let _ = w.tx.try_send(SseFrame::Done);
419 closed = true;
420 }
421 }
422 lane.owed = 0;
423 lane.in_turn = false;
424 if lane.is_idle() {
425 inner.lanes.remove(jid);
426 }
427 }
428 if closed {
429 inner.total_writers = inner.total_writers.saturating_sub(1);
430 }
431 closed
432 }
433
434 pub(crate) fn send_text(&self, jid: &str, text: &str) -> SendOutcome {
437 let mut inner = self.lock();
438 let Some(lane) = inner.lanes.get_mut(jid) else {
439 return SendOutcome::NoWriter;
440 };
441 let Some(w) = lane.active.as_mut() else {
442 return SendOutcome::NoWriter;
443 };
444 w.answered = true;
445 match w.tx.try_send(SseFrame::Text(text.to_string())) {
446 Ok(()) => SendOutcome::Delivered,
447 Err(mpsc::error::TrySendError::Full(_)) => SendOutcome::Overflow,
448 Err(mpsc::error::TrySendError::Closed(_)) => {
449 lane.active = None;
450 inner.total_writers = inner.total_writers.saturating_sub(1);
451 SendOutcome::ClientGone
452 }
453 }
454 }
455
456 pub(crate) fn has_streams(&self, jid: &str) -> bool {
458 self.lock()
459 .lanes
460 .get(jid)
461 .is_some_and(|l| l.writer_count() > 0)
462 }
463
464 pub(crate) fn pending_len(&self, jid: &str) -> usize {
466 self.lock().lanes.get(jid).map_or(0, |l| l.pending.len())
467 }
468
469 #[cfg(test)]
471 pub(crate) fn has_active(&self, jid: &str) -> bool {
472 self.lock()
473 .lanes
474 .get(jid)
475 .is_some_and(|l| l.active.is_some())
476 }
477
478 pub(crate) fn counts(&self) -> StreamCounts {
480 let inner = self.lock();
481 let mut c = StreamCounts::default();
482 for lane in inner.lanes.values() {
483 c.pending += lane.pending.len();
484 c.active += usize::from(lane.active.is_some());
485 c.lanes_in_turn += usize::from(lane.in_turn);
486 }
487 c
488 }
489
490 pub(crate) fn forget_lane(&self, jid: &str) {
493 let mut inner = self.lock();
494 if let Some(lane) = inner.lanes.remove(jid) {
495 let n = lane.writer_count();
496 inner.total_writers = inner.total_writers.saturating_sub(n);
497 }
498 }
499
500 pub(crate) fn close_all(&self) {
502 let mut inner = self.lock();
503 for (_, lane) in inner.lanes.drain() {
504 if let Some(w) = lane.active {
505 let _ = w.tx.try_send(SseFrame::Done);
506 }
507 }
508 inner.total_writers = 0;
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use serde_json::json;
516
517 const COPILOT_JID: &str = "http:copilot";
518 const INVESTIGATE_JID: &str = "http:copilot:investigate";
519
520 fn ids() -> LaneIds {
521 LaneIds::new(COPILOT_JID)
522 }
523
524 struct Enqueued {
525 id: u64,
526 rx: mpsc::Receiver<SseFrame>,
527 }
528
529 impl Enqueued {
530 fn texts(&mut self) -> Vec<String> {
532 self.drain()
533 .into_iter()
534 .filter_map(|f| match f {
535 SseFrame::Text(t) => Some(t),
536 SseFrame::Done => None,
537 })
538 .collect()
539 }
540
541 fn drain(&mut self) -> Vec<SseFrame> {
542 let mut out = Vec::new();
543 while let Ok(f) = self.rx.try_recv() {
544 out.push(f);
545 }
546 out
547 }
548
549 fn ended(&mut self) -> bool {
551 matches!(
552 self.rx.try_recv(),
553 Err(mpsc::error::TryRecvError::Disconnected)
554 )
555 }
556 }
557
558 fn enqueue(reg: &StreamRegistry, jid: &str) -> Enqueued {
559 let (id, rx) = reg.open(jid, 16, 1024).unwrap();
560 Enqueued { id, rx }
561 }
562
563 #[test]
566 fn writes_a_lane_response_only_to_that_lanes_writer() {
567 let reg = StreamRegistry::default();
568 let mut chat = enqueue(®, COPILOT_JID);
569 let mut investigate = enqueue(®, INVESTIGATE_JID);
570 reg.turn_started(COPILOT_JID);
571 reg.turn_started(INVESTIGATE_JID);
572 reg.send_text(COPILOT_JID, "answer for the analyst");
573 reg.send_text(INVESTIGATE_JID, "investigation report");
574 assert_eq!(chat.texts(), vec!["answer for the analyst"]);
575 assert_eq!(investigate.texts(), vec!["investigation report"]);
576 }
577
578 #[test]
579 fn drops_output_for_a_lane_with_no_writer_instead_of_borrowing_another() {
580 let reg = StreamRegistry::default();
581 let mut chat = enqueue(®, COPILOT_JID);
582 reg.turn_started(COPILOT_JID);
583 assert_eq!(
584 reg.send_text(INVESTIGATE_JID, "investigation report"),
585 SendOutcome::NoWriter
586 );
587 assert!(chat.texts().is_empty());
588 assert!(!chat.ended());
589 }
590
591 #[test]
592 fn one_lanes_turn_start_does_not_steal_another_lanes_queued_writer() {
593 let reg = StreamRegistry::default();
594 let mut chat = enqueue(®, COPILOT_JID);
595 reg.turn_started(INVESTIGATE_JID);
596 reg.send_text(COPILOT_JID, "still waiting");
597 assert!(chat.texts().is_empty());
598 reg.turn_started(COPILOT_JID);
599 reg.send_text(COPILOT_JID, "answer for the analyst");
600 assert_eq!(chat.texts(), vec!["answer for the analyst"]);
601 }
602
603 #[test]
604 fn closes_only_the_completing_lanes_stream() {
605 let reg = StreamRegistry::default();
606 let mut chat = enqueue(®, COPILOT_JID);
607 let mut investigate = enqueue(®, INVESTIGATE_JID);
608 reg.turn_started(COPILOT_JID);
609 reg.turn_started(INVESTIGATE_JID);
610 assert!(reg.turn_complete(INVESTIGATE_JID));
611 assert_eq!(investigate.drain(), vec![SseFrame::Done]);
612 assert!(investigate.ended());
613 assert!(chat.drain().is_empty());
614 assert!(!chat.ended());
615 }
616
617 #[test]
618 fn serialises_two_callers_on_the_same_lane() {
619 let reg = StreamRegistry::default();
620 let mut first = enqueue(®, COPILOT_JID);
621 let mut second = enqueue(®, COPILOT_JID);
622 reg.turn_started(COPILOT_JID);
623 reg.send_text(COPILOT_JID, "first answer");
624 reg.turn_complete(COPILOT_JID);
625 reg.turn_started(COPILOT_JID);
626 reg.send_text(COPILOT_JID, "second answer");
627 assert_eq!(first.texts(), vec!["first answer"]);
628 assert_eq!(second.texts(), vec!["second answer"]);
629 }
630
631 #[test]
634 fn follow_up_turn_start_does_not_hijack_the_active_writer() {
635 let reg = StreamRegistry::default();
638 let mut first = enqueue(®, COPILOT_JID);
639 reg.turn_started(COPILOT_JID);
640 let mut second = enqueue(®, COPILOT_JID);
641 reg.turn_started(COPILOT_JID); reg.send_text(COPILOT_JID, "rest of first answer");
643 reg.turn_complete(COPILOT_JID);
644 reg.send_text(COPILOT_JID, "second answer");
645 reg.turn_complete(COPILOT_JID);
646 assert_eq!(first.texts(), vec!["rest of first answer"]);
647 assert!(first.ended());
648 assert_eq!(second.texts(), vec!["second answer"]);
649 assert!(second.ended());
650 assert_eq!(reg.counts(), StreamCounts::default());
651 }
652
653 #[test]
654 fn container_exit_closes_the_active_writer_as_a_safety_net() {
655 let reg = StreamRegistry::default();
656 let mut w = enqueue(®, COPILOT_JID);
657 reg.turn_started(COPILOT_JID);
658 reg.send_text(COPILOT_JID, "partial answer");
659 assert!(reg.turn_ended(COPILOT_JID));
660 assert_eq!(
661 w.drain(),
662 vec![SseFrame::Text("partial answer".into()), SseFrame::Done]
663 );
664 assert!(w.ended());
665 assert!(!reg.turn_ended(COPILOT_JID));
666 }
667
668 #[test]
669 fn an_unanswered_writer_gets_the_retry_not_the_next_caller() {
670 let reg = StreamRegistry::default();
671 let mut first = enqueue(®, COPILOT_JID);
672 reg.turn_started(COPILOT_JID);
673 let mut second = enqueue(®, COPILOT_JID);
674 assert!(!reg.turn_ended(COPILOT_JID));
676 assert!(first.drain().is_empty());
677 assert_eq!(reg.pending_len(COPILOT_JID), 2);
678 reg.turn_started(COPILOT_JID);
680 reg.send_text(COPILOT_JID, "first answer");
681 reg.turn_complete(COPILOT_JID);
682 reg.turn_ended(COPILOT_JID);
683 assert_eq!(first.texts(), vec!["first answer"]);
684 assert!(first.ended());
685 assert!(second.drain().is_empty());
686 assert_eq!(reg.pending_len(COPILOT_JID), 1);
687 }
688
689 #[test]
690 fn an_unanswered_writer_is_closed_after_too_many_failed_turns() {
691 let reg = StreamRegistry::default();
692 let mut w = enqueue(®, COPILOT_JID);
693 for _ in 0..MAX_REQUEUES {
694 reg.turn_started(COPILOT_JID);
695 assert!(!reg.turn_ended(COPILOT_JID));
696 }
697 reg.turn_started(COPILOT_JID);
698 assert!(reg.turn_ended(COPILOT_JID));
699 assert_eq!(w.drain(), vec![SseFrame::Done]);
700 assert_eq!(reg.counts(), StreamCounts::default());
701 }
702
703 #[test]
704 fn disconnect_removes_pending_and_active_writers() {
705 let reg = StreamRegistry::default();
706 let a = enqueue(®, COPILOT_JID);
707 let b = enqueue(®, COPILOT_JID);
708 reg.turn_started(COPILOT_JID);
709 assert!(reg.has_active(COPILOT_JID));
710 reg.remove(COPILOT_JID, a.id);
711 assert!(!reg.has_active(COPILOT_JID));
712 assert_eq!(reg.pending_len(COPILOT_JID), 1);
713 reg.remove(COPILOT_JID, b.id);
714 assert!(!reg.has_streams(COPILOT_JID));
715 let c = enqueue(®, COPILOT_JID);
717 reg.turn_started(COPILOT_JID);
718 drop(c);
719 assert_eq!(reg.send_text(COPILOT_JID, "x"), SendOutcome::ClientGone);
720 reg.turn_ended(COPILOT_JID);
721 assert_eq!(reg.counts(), StreamCounts::default());
722 }
723
724 #[test]
725 fn caps_are_enforced() {
726 let reg = StreamRegistry::default();
727 assert!(reg.open(COPILOT_JID, 1, 10).is_ok());
728 assert_eq!(
729 reg.open(COPILOT_JID, 1, 10).err(),
730 Some(OpenError::LaneBusy)
731 );
732 assert!(reg.open("http:copilot:u:1", 1, 2).is_ok());
733 assert_eq!(
734 reg.open("http:copilot:u:2", 1, 2).err(),
735 Some(OpenError::TooManyStreams)
736 );
737 }
738
739 #[test]
740 fn full_buffer_drops_chunks_without_blocking() {
741 let reg = StreamRegistry::default();
742 let mut w = enqueue(®, COPILOT_JID);
743 reg.turn_started(COPILOT_JID);
744 for _ in 0..WRITER_BUFFER {
745 assert_eq!(reg.send_text(COPILOT_JID, "x"), SendOutcome::Delivered);
746 }
747 assert_eq!(reg.send_text(COPILOT_JID, "x"), SendOutcome::Overflow);
748 assert_eq!(w.texts().len(), WRITER_BUFFER);
749 }
750
751 #[test]
752 fn close_all_ends_every_stream() {
753 let reg = StreamRegistry::default();
754 let mut a = enqueue(®, COPILOT_JID);
755 let mut b = enqueue(®, INVESTIGATE_JID);
756 reg.turn_started(COPILOT_JID);
757 reg.close_all();
758 assert_eq!(a.drain(), vec![SseFrame::Done]);
759 assert!(a.ended());
760 assert!(b.ended());
761 assert_eq!(reg.counts(), StreamCounts::default());
762 }
763
764 #[test]
765 fn frames_serialize_like_the_ts_channel() {
766 assert_eq!(
767 SseFrame::Text("hi \"x\"\n".into()).to_json(),
768 r#"{"type":"text","content":"hi \"x\"\n"}"#
769 );
770 assert_eq!(SseFrame::Done.to_json(), r#"{"type":"done"}"#);
771 }
772
773 #[test]
776 fn owns_the_chat_lane_and_every_sub_lane() {
777 let l = ids();
778 assert!(l.owns(COPILOT_JID));
779 assert!(l.owns(INVESTIGATE_JID));
780 assert!(l.owns("http:copilot:u:42"));
781 }
782
783 #[test]
784 fn does_not_own_another_channels_jid() {
785 let l = ids();
786 assert!(!l.owns("webhook:copilot"));
787 assert!(!l.owns("telegram:123"));
788 assert!(!l.owns("http:copilotx"));
789 assert!(!l.owns("http:copilo"));
790 }
791
792 fn parse(v: Value) -> Result<Option<String>, InvalidUserId> {
793 UserId::from_json(Some(&v)).map(|o| o.map(|u| u.as_str().to_string()))
794 }
795
796 #[test]
797 fn accepts_ids_copilot_actually_issues() {
798 assert_eq!(parse(json!(42)).unwrap().as_deref(), Some("42"));
799 assert_eq!(
800 parse(json!("analyst-7")).unwrap().as_deref(),
801 Some("analyst-7")
802 );
803 assert_eq!(parse(json!("a_b-C9")).unwrap().as_deref(), Some("a_b-C9"));
804 let max = "x".repeat(MAX_USER_ID_CHARS);
805 assert_eq!(parse(json!(max.clone())).unwrap(), Some(max));
806 }
807
808 #[test]
809 fn rejects_ids_instead_of_stripping_them_into_a_neighbour() {
810 for raw in [
813 "bob.smith",
814 "../../etc/passwd",
815 "a:b",
816 " bob",
817 "bob ",
818 "ünïcödé-1",
819 "a/b",
820 "///",
821 ] {
822 assert_eq!(parse(json!(raw)), Err(InvalidUserId), "{raw:?}");
823 }
824 assert_eq!(
825 parse(json!("x".repeat(MAX_USER_ID_CHARS + 1))),
826 Err(InvalidUserId)
827 );
828 for v in [json!({ "id": 1 }), json!(true), json!(["1"]), json!(1.5)] {
829 assert_eq!(parse(v.clone()), Err(InvalidUserId), "{v}");
830 }
831 assert!(UserId::parse("bob.smith").is_err());
832 assert!(is_valid_user_id("bobsmith"));
833 }
834
835 #[test]
836 fn absent_ids_map_to_the_anon_lane() {
837 assert_eq!(UserId::from_json(None), Ok(None));
838 assert_eq!(parse(Value::Null), Ok(None));
839 assert_eq!(parse(json!("")), Ok(None));
840 }
841
842 #[test]
843 fn session_key_has_the_documented_format() {
844 let bob = UserId::parse("bob").unwrap();
845 assert_eq!(
847 bob.session_key(),
848 "copilot-u-bob-81b637d8fcd2c6da6359e6963113a117"
849 );
850 let upper = UserId::parse("Bob").unwrap();
851 assert_eq!(
853 upper.session_key(),
854 "copilot-u-bob-cd9fb1e148ccd8442e5aa74904cc73bf"
855 );
856 assert_eq!(
857 UserId::parse("42").unwrap().session_key(),
858 "copilot-u-42-73475cb40a568e8da8a045ced110137e"
859 );
860 }
861
862 #[test]
863 fn session_key_carries_a_128_bit_hash_of_the_exact_id() {
864 for raw in ["bob", "Bob", "42", &"Q".repeat(MAX_USER_ID_CHARS)] {
867 let id = UserId::parse(raw).unwrap();
868 let key = id.session_key();
869 let (head, hash) = key.rsplit_once('-').unwrap();
870 assert_eq!(hash.len(), USER_KEY_HASH_HEX, "{key}");
871 assert!(hash.len() * 4 >= 128, "{key}");
872 assert!(
873 hash.bytes()
874 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
875 );
876 let digest = Sha256::digest(raw.as_bytes());
877 let full: String = digest.iter().map(|b| format!("{b:02x}")).collect();
878 assert_eq!(hash, &full[..USER_KEY_HASH_HEX]);
879 let prefix = head.strip_prefix(USER_SESSION_KEY_PREFIX).unwrap();
880 assert!(prefix.len() <= USER_KEY_ID_CHARS, "{key}");
881 assert!(raw.to_ascii_lowercase().starts_with(prefix), "{key}");
882 }
883 }
884
885 #[test]
886 fn ids_sharing_a_long_prefix_get_distinct_full_hash_keys() {
887 let base = "victim-account-with-a-long-identifier-00";
890 let victim = UserId::parse(&format!("{base}zz")).unwrap().session_key();
891 assert_eq!(
892 victim.len(),
893 USER_SESSION_KEY_PREFIX.len() + USER_KEY_ID_CHARS + 1 + USER_KEY_HASH_HEX
894 );
895 let mut seen = std::collections::HashSet::new();
896 seen.insert(victim.clone());
897 for i in 0..2_000u32 {
898 let k = UserId::parse(&format!("{base}{i:x}"))
899 .unwrap()
900 .session_key();
901 assert_eq!(k.len(), victim.len());
902 assert!(seen.insert(k), "duplicate key for suffix {i:x}");
903 }
904 }
905
906 #[test]
907 fn session_keys_never_collide_on_case_insensitive_filesystems() {
908 let ids = [
909 "bob",
910 "Bob",
911 "BOB",
912 "bOb",
913 "bobsmith",
914 "bob_smith",
915 "bob-smith",
916 ];
917 let mut keys: Vec<String> = ids
918 .iter()
919 .map(|i| UserId::parse(i).unwrap().session_key().to_ascii_lowercase())
920 .collect();
921 keys.sort();
922 keys.dedup();
923 assert_eq!(keys.len(), ids.len(), "{keys:?}");
924 let base = "a".repeat(USER_KEY_ID_CHARS);
926 let k1 = UserId::parse(&format!("{base}1")).unwrap().session_key();
927 let k2 = UserId::parse(&format!("{base}2")).unwrap().session_key();
928 assert_ne!(k1, k2);
929 }
930
931 #[test]
932 fn every_accepted_id_yields_a_valid_lane_key() {
933 for raw in [
934 "42".to_string(),
935 "analyst-7".to_string(),
936 "-leading-dash".to_string(),
937 "Z".repeat(MAX_USER_ID_CHARS),
938 "_".repeat(MAX_USER_ID_CHARS),
939 "-".repeat(MAX_USER_ID_CHARS),
940 "9".repeat(USER_KEY_ID_CHARS + 1),
941 ] {
942 let key = UserId::parse(&raw).unwrap().session_key();
943 assert!(key.len() <= MAX_LANE_KEY_CHARS, "{key}");
944 assert_eq!(
945 key.len(),
946 USER_SESSION_KEY_PREFIX.len()
947 + raw.len().min(USER_KEY_ID_CHARS)
948 + 1
949 + USER_KEY_HASH_HEX
950 );
951 assert!(key.starts_with(USER_SESSION_KEY_PREFIX));
952 assert!(talon_core::paths::is_valid_group_folder(&key), "{key}");
953 assert_eq!(key, key.to_ascii_lowercase());
954 }
955 }
956
957 #[test]
958 fn lane_jid_helpers() {
959 assert_eq!(lane_jid_for_user("42"), "http:copilot:u:42");
960 let l = ids();
961 assert!(l.is_user_lane("http:copilot:u:42"));
962 assert!(!l.is_user_lane("http:copilot:u:"));
963 assert!(!l.is_user_lane(INVESTIGATE_JID));
964 assert_eq!(l.investigate, INVESTIGATE_JID);
965 }
966}