1use crate::error::MacpError;
2use crate::mode::ModeResponse;
3use crate::policy::PolicyDefinition;
4use macp_pb::pb::SessionStartPayload;
5use prost::Message;
6use std::collections::{HashMap, HashSet};
7
8pub const MAX_TTL_MS: i64 = 24 * 60 * 60 * 1000;
9
10pub const MAX_SUSPEND_MS: i64 = 7 * 24 * 60 * 60 * 1000;
17
18pub const CURRENT_SEMANTICS_REV: u32 = 1;
29
30#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
31pub enum SessionState {
32 Open,
33 Suspended,
37 Resolved,
38 Expired,
39 Cancelled,
41}
42
43impl SessionState {
44 pub fn is_terminal(&self) -> bool {
46 matches!(
47 self,
48 SessionState::Resolved | SessionState::Expired | SessionState::Cancelled
49 )
50 }
51}
52
53#[non_exhaustive]
58#[derive(Clone, Debug)]
59pub struct Session {
60 pub session_id: String,
61 pub state: SessionState,
62 pub ttl_expiry: i64,
63 pub ttl_ms: i64,
64 pub started_at_unix_ms: i64,
65 pub resolution: Option<Vec<u8>>,
66 pub mode: String,
67 pub mode_state: Vec<u8>,
68 pub participants: Vec<String>,
69 pub seen_message_ids: HashSet<String>,
70 pub intent: String,
71 pub mode_version: String,
72 pub configuration_version: String,
73 pub policy_version: String,
74 pub context_id: String,
75 pub extensions: HashMap<String, Vec<u8>>,
76 pub roots: Vec<macp_pb::pb::Root>,
77 pub initiator_sender: String,
78 pub participant_message_counts: HashMap<String, u32>,
79 pub participant_last_seen: HashMap<String, i64>,
80 pub policy_definition: Option<PolicyDefinition>,
81 pub suspended_at_ms: Option<i64>,
84 pub accumulated_suspended_ms: i64,
87 pub semantics_rev: u32,
90 pub max_suspend_ms: i64,
96}
97
98impl Session {
99 pub fn builder(
103 session_id: impl Into<String>,
104 mode: impl Into<String>,
105 initiator_sender: impl Into<String>,
106 ) -> SessionBuilder {
107 SessionBuilder {
108 inner: Session {
109 session_id: session_id.into(),
110 state: SessionState::Open,
111 ttl_expiry: i64::MAX,
115 ttl_ms: 0,
116 started_at_unix_ms: 0,
117 resolution: None,
118 mode: mode.into(),
119 mode_state: vec![],
120 participants: vec![],
121 seen_message_ids: HashSet::new(),
122 intent: String::new(),
123 mode_version: String::new(),
124 configuration_version: String::new(),
125 policy_version: String::new(),
126 context_id: String::new(),
127 extensions: HashMap::new(),
128 roots: vec![],
129 initiator_sender: initiator_sender.into(),
130 participant_message_counts: HashMap::new(),
131 participant_last_seen: HashMap::new(),
132 policy_definition: None,
133 suspended_at_ms: None,
134 accumulated_suspended_ms: 0,
135 semantics_rev: CURRENT_SEMANTICS_REV,
136 max_suspend_ms: 0,
137 },
138 }
139 }
140
141 pub fn record_participant_activity(&mut self, sender: &str, timestamp_ms: i64) {
142 *self
143 .participant_message_counts
144 .entry(sender.to_string())
145 .or_insert(0) += 1;
146 self.participant_last_seen
147 .insert(sender.to_string(), timestamp_ms);
148 }
149
150 pub fn suspend(&mut self, now_ms: i64) -> Result<(), MacpError> {
154 if self.state != SessionState::Open {
155 return Err(MacpError::SessionNotOpen);
156 }
157 self.state = SessionState::Suspended;
158 self.suspended_at_ms = Some(now_ms);
159 Ok(())
160 }
161
162 pub fn effective_max_suspend_ms(&self) -> i64 {
169 if self.max_suspend_ms > 0 {
170 self.max_suspend_ms
171 } else {
172 MAX_SUSPEND_MS
173 }
174 }
175
176 pub fn resume(&mut self, now_ms: i64) -> Result<(), MacpError> {
177 if self.state != SessionState::Suspended {
178 return Err(MacpError::SessionNotOpen);
179 }
180 let suspended_at = self.suspended_at_ms.unwrap_or(now_ms);
181 let banked = (now_ms - suspended_at).max(0);
182 self.accumulated_suspended_ms = self.accumulated_suspended_ms.saturating_add(banked);
183 self.suspended_at_ms = None;
184 if self.accumulated_suspended_ms > self.effective_max_suspend_ms() {
185 self.state = SessionState::Expired;
186 return Err(MacpError::TtlExpired);
187 }
188 self.ttl_expiry = self.ttl_expiry.saturating_add(banked);
189 self.state = SessionState::Open;
190 Ok(())
191 }
192
193 pub fn cancel(&mut self) -> Result<(), MacpError> {
196 if self.state.is_terminal() {
197 return Err(MacpError::SessionNotOpen);
198 }
199 self.state = SessionState::Cancelled;
200 self.suspended_at_ms = None;
201 Ok(())
202 }
203
204 pub fn suspend_cap_exceeded(&self, now_ms: i64) -> bool {
207 match self.suspended_at_ms {
208 Some(at) => {
209 self.accumulated_suspended_ms
210 .saturating_add((now_ms - at).max(0))
211 > self.effective_max_suspend_ms()
212 }
213 None => self.accumulated_suspended_ms > self.effective_max_suspend_ms(),
214 }
215 }
216
217 pub fn apply_mode_response(&mut self, response: ModeResponse) {
218 match response {
219 ModeResponse::NoOp => {}
220 ModeResponse::PersistState(state) => self.mode_state = state,
221 ModeResponse::Resolve(resolution) => {
222 self.state = SessionState::Resolved;
223 self.resolution = Some(resolution);
224 }
225 ModeResponse::PersistAndResolve { state, resolution } => {
226 self.mode_state = state;
227 self.state = SessionState::Resolved;
228 self.resolution = Some(resolution);
229 }
230 }
231 }
232}
233
234#[derive(Clone, Debug)]
240pub struct SessionBuilder {
241 inner: Session,
242}
243
244macro_rules! builder_setters {
245 ($($(#[$doc:meta])* $name:ident: $ty:ty),* $(,)?) => {
246 $(
247 $(#[$doc])*
248 pub fn $name(mut self, value: $ty) -> Self {
249 self.inner.$name = value;
250 self
251 }
252 )*
253 };
254}
255
256impl SessionBuilder {
257 builder_setters! {
258 state: SessionState,
259 ttl_expiry: i64,
260 ttl_ms: i64,
261 started_at_unix_ms: i64,
262 resolution: Option<Vec<u8>>,
263 mode_state: Vec<u8>,
264 participants: Vec<String>,
265 seen_message_ids: HashSet<String>,
266 extensions: HashMap<String, Vec<u8>>,
267 roots: Vec<macp_pb::pb::Root>,
268 participant_message_counts: HashMap<String, u32>,
269 participant_last_seen: HashMap<String, i64>,
270 policy_definition: Option<crate::policy::PolicyDefinition>,
271 suspended_at_ms: Option<i64>,
272 accumulated_suspended_ms: i64,
273 semantics_rev: u32,
274 max_suspend_ms: i64,
277 }
278
279 pub fn intent(mut self, value: impl Into<String>) -> Self {
280 self.inner.intent = value.into();
281 self
282 }
283
284 pub fn mode_version(mut self, value: impl Into<String>) -> Self {
285 self.inner.mode_version = value.into();
286 self
287 }
288
289 pub fn configuration_version(mut self, value: impl Into<String>) -> Self {
290 self.inner.configuration_version = value.into();
291 self
292 }
293
294 pub fn policy_version(mut self, value: impl Into<String>) -> Self {
295 self.inner.policy_version = value.into();
296 self
297 }
298
299 pub fn context_id(mut self, value: impl Into<String>) -> Self {
300 self.inner.context_id = value.into();
301 self
302 }
303
304 pub fn build(self) -> Session {
305 self.inner
306 }
307}
308
309pub fn requires_strict_session_start(mode: &str) -> bool {
310 matches!(
311 mode,
312 "macp.mode.decision.v1"
313 | "macp.mode.proposal.v1"
314 | "macp.mode.task.v1"
315 | "macp.mode.handoff.v1"
316 | "macp.mode.quorum.v1"
317 | "ext.multi_round.v1"
318 )
319}
320
321pub fn parse_session_start_payload(payload: &[u8]) -> Result<SessionStartPayload, MacpError> {
323 if payload.is_empty() {
324 return Err(MacpError::InvalidPayload);
325 }
326 SessionStartPayload::decode(payload).map_err(|_| MacpError::InvalidPayload)
327}
328
329pub fn extract_ttl_ms(payload: &SessionStartPayload) -> Result<i64, MacpError> {
331 if !(1..=MAX_TTL_MS).contains(&payload.ttl_ms) {
332 return Err(MacpError::InvalidTtl);
333 }
334 Ok(payload.ttl_ms)
335}
336
337fn allows_empty_participants(mode: &str) -> bool {
360 mode == "macp.mode.decision.v1"
361}
362
363pub fn validate_canonical_session_start_payload(
371 payload: &SessionStartPayload,
372) -> Result<(), MacpError> {
373 validate_canonical_start(payload, false)
374}
375
376pub fn validate_canonical_session_start_payload_for_mode(
389 mode: &str,
390 payload: &SessionStartPayload,
391) -> Result<(), MacpError> {
392 validate_canonical_start(payload, allows_empty_participants(mode))
393}
394
395fn validate_canonical_start(
396 payload: &SessionStartPayload,
397 allow_empty_participants: bool,
398) -> Result<(), MacpError> {
399 extract_ttl_ms(payload)?;
400
401 if payload.mode_version.trim().is_empty() || payload.configuration_version.trim().is_empty() {
402 return Err(MacpError::InvalidPayload);
403 }
404
405 if payload.participants.is_empty() && !allow_empty_participants {
406 return Err(MacpError::InvalidPayload);
407 }
408
409 const MAX_PARTICIPANTS: usize = 1000;
411 if payload.participants.len() > MAX_PARTICIPANTS {
412 return Err(MacpError::InvalidPayload);
413 }
414
415 let mut seen = HashSet::new();
416 for participant in &payload.participants {
417 let participant = participant.trim();
418 if participant.is_empty() || !seen.insert(participant.to_string()) {
419 return Err(MacpError::InvalidPayload);
420 }
421 }
422
423 if payload.max_suspend_ms < 0 {
426 return Err(MacpError::InvalidPayload);
427 }
428
429 Ok(())
430}
431
432pub fn validate_strict_session_start_payload(
434 mode: &str,
435 payload: &SessionStartPayload,
436) -> Result<(), MacpError> {
437 if !requires_strict_session_start(mode) {
438 return Ok(());
439 }
440
441 validate_canonical_session_start_payload_for_mode(mode, payload)
442}
443
444pub fn validate_session_id_for_acceptance(session_id: &str) -> Result<(), MacpError> {
452 if session_id.is_empty() {
453 return Err(MacpError::InvalidSessionId);
454 }
455
456 if session_id.len() == 36 && session_id.contains('-') {
462 if let Ok(parsed) = uuid::Uuid::parse_str(session_id) {
463 if parsed.as_hyphenated().to_string() == session_id {
464 match parsed.get_version() {
465 Some(uuid::Version::Random) | Some(uuid::Version::SortRand) => {
466 return Ok(());
467 }
468 _ => {}
469 }
470 }
471 return Err(MacpError::InvalidSessionId);
472 }
473 }
474
475 if session_id.len() >= 22
477 && session_id
478 .chars()
479 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
480 {
481 return Ok(());
482 }
483
484 Err(MacpError::InvalidSessionId)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use prost::Message;
491
492 fn encode_payload(ttl_ms: i64, participants: Vec<String>) -> Vec<u8> {
493 let payload = SessionStartPayload {
494 intent: String::new(),
495 participants,
496 mode_version: "1.0.0".into(),
497 configuration_version: "cfg-1".into(),
498 policy_version: String::new(),
499 ttl_ms,
500 context_id: String::new(),
501 extensions: std::collections::HashMap::new(),
502 roots: vec![],
503 max_suspend_ms: 0,
504 };
505 payload.encode_to_vec()
506 }
507
508 #[test]
509 fn parse_empty_payload_is_invalid() {
510 let err = parse_session_start_payload(b"").unwrap_err();
511 assert_eq!(err.to_string(), "InvalidPayload");
512 }
513
514 #[test]
515 fn parse_valid_protobuf_payload() {
516 let bytes = encode_payload(5000, vec!["alice".into(), "bob".into()]);
517 let result = parse_session_start_payload(&bytes).unwrap();
518 assert_eq!(result.ttl_ms, 5000);
519 assert_eq!(result.participants, vec!["alice", "bob"]);
520 }
521
522 #[test]
523 fn extract_ttl_requires_explicit_positive_value() {
524 let payload = SessionStartPayload::default();
525 assert_eq!(
526 extract_ttl_ms(&payload).unwrap_err().to_string(),
527 "InvalidTtl"
528 );
529
530 let payload = SessionStartPayload {
531 ttl_ms: 5000,
532 ..Default::default()
533 };
534 assert_eq!(extract_ttl_ms(&payload).unwrap(), 5000);
535 }
536
537 #[test]
538 fn standard_mode_requires_explicit_versions() {
539 let payload = SessionStartPayload {
546 participants: vec!["alice".into()],
547 mode_version: String::new(),
548 configuration_version: "cfg-1".into(),
549 ttl_ms: 1000,
550 ..Default::default()
551 };
552 assert_eq!(
553 validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
554 .unwrap_err()
555 .to_string(),
556 "InvalidPayload"
557 );
558
559 let payload = SessionStartPayload {
560 participants: vec!["alice".into()],
561 mode_version: "1.0.0".into(),
562 configuration_version: String::new(),
563 ttl_ms: 1000,
564 ..Default::default()
565 };
566 assert_eq!(
567 validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
568 .unwrap_err()
569 .to_string(),
570 "InvalidPayload"
571 );
572
573 let payload = SessionStartPayload {
574 participants: vec!["alice".into()],
575 mode_version: "1.0.0".into(),
576 configuration_version: "cfg-1".into(),
577 ttl_ms: 0,
578 ..Default::default()
579 };
580 assert_eq!(
581 validate_strict_session_start_payload("macp.mode.decision.v1", &payload)
582 .unwrap_err()
583 .to_string(),
584 "InvalidTtl"
585 );
586 }
587
588 fn empty_roster_payload() -> SessionStartPayload {
590 SessionStartPayload {
591 participants: vec![],
592 mode_version: "1.0.0".into(),
593 configuration_version: "cfg-1".into(),
594 ttl_ms: 1000,
595 ..Default::default()
596 }
597 }
598
599 #[test]
600 fn decision_accepts_an_empty_participant_list() {
601 assert!(
602 validate_strict_session_start_payload("macp.mode.decision.v1", &empty_roster_payload())
603 .is_ok(),
604 "RFC-MACP-0001 §7.1 requires participants only when the mode does, and \
605 RFC-MACP-0007 makes Decision authority role-based; spec #99's \
606 decision_zero_participants.json pins the accepted SessionStart"
607 );
608 let mut broken = empty_roster_payload();
611 broken.mode_version = String::new();
612 assert!(
613 validate_strict_session_start_payload("macp.mode.decision.v1", &broken).is_err(),
614 "an empty roster must not waive the rest of the strict contract"
615 );
616 }
617
618 #[test]
619 fn every_standard_mode_except_decision_rejects_an_empty_roster() {
620 for mode in [
627 "macp.mode.proposal.v1",
628 "macp.mode.task.v1",
629 "macp.mode.handoff.v1",
630 "macp.mode.quorum.v1",
631 "ext.multi_round.v1",
632 ] {
633 assert!(
634 requires_strict_session_start(mode),
635 "{mode} must be strict for this table to mean anything"
636 );
637 assert_eq!(
638 validate_strict_session_start_payload(mode, &empty_roster_payload())
639 .unwrap_err()
640 .to_string(),
641 "InvalidPayload",
642 "{mode} must still reject an empty participant list at the core layer"
643 );
644 }
645
646 assert_eq!(
653 validate_canonical_session_start_payload_for_mode(
654 "ext.promoted.v1",
655 &empty_roster_payload()
656 )
657 .unwrap_err()
658 .to_string(),
659 "InvalidPayload",
660 "an unrecognised (e.g. promoted) mode must default to the strict roster rule"
661 );
662
663 assert_eq!(
666 validate_canonical_session_start_payload(&empty_roster_payload())
667 .unwrap_err()
668 .to_string(),
669 "InvalidPayload"
670 );
671 }
672
673 fn open_session(ttl_expiry: i64) -> Session {
674 Session {
675 session_id: "s1".into(),
676 state: SessionState::Open,
677 ttl_expiry,
678 ttl_ms: 60_000,
679 started_at_unix_ms: 0,
680 resolution: None,
681 mode: "macp.mode.decision.v1".into(),
682 mode_state: vec![],
683 participants: vec![],
684 seen_message_ids: HashSet::new(),
685 intent: String::new(),
686 mode_version: "1.0.0".into(),
687 configuration_version: "cfg-1".into(),
688 policy_version: String::new(),
689 context_id: String::new(),
690 extensions: HashMap::new(),
691 roots: vec![],
692 initiator_sender: "agent://a".into(),
693 participant_message_counts: HashMap::new(),
694 participant_last_seen: HashMap::new(),
695 policy_definition: None,
696 suspended_at_ms: None,
697 accumulated_suspended_ms: 0,
698 semantics_rev: CURRENT_SEMANTICS_REV,
699 max_suspend_ms: 0,
700 }
701 }
702
703 #[test]
704 fn suspend_then_resume_banks_ttl() {
705 let mut s = open_session(10_000);
706 s.suspend(2_000).unwrap();
707 assert_eq!(s.state, SessionState::Suspended);
708 assert_eq!(s.suspended_at_ms, Some(2_000));
709 s.resume(5_000).unwrap();
711 assert_eq!(s.state, SessionState::Open);
712 assert_eq!(s.ttl_expiry, 13_000);
713 assert_eq!(s.accumulated_suspended_ms, 3_000);
714 assert_eq!(s.suspended_at_ms, None);
715 }
716
717 #[test]
718 fn suspend_requires_open_and_resume_requires_suspended() {
719 let mut s = open_session(10_000);
720 assert!(matches!(
722 s.resume(1).unwrap_err(),
723 MacpError::SessionNotOpen
724 ));
725 s.suspend(1).unwrap();
726 assert!(matches!(
728 s.suspend(2).unwrap_err(),
729 MacpError::SessionNotOpen
730 ));
731 }
732
733 #[test]
734 fn resume_exceeding_max_suspend_expires() {
735 let mut s = open_session(10_000);
736 s.suspend(0).unwrap();
737 let err = s.resume(MAX_SUSPEND_MS + 1).unwrap_err();
739 assert!(matches!(err, MacpError::TtlExpired));
740 assert_eq!(s.state, SessionState::Expired);
741 }
742
743 #[test]
747 fn bound_cap_overrides_default_on_resume() {
748 let mut s = open_session(10_000);
749 s.max_suspend_ms = 500;
750 s.suspend(0).unwrap();
751 let err = s.resume(501).unwrap_err();
752 assert!(matches!(err, MacpError::TtlExpired));
753 assert_eq!(s.state, SessionState::Expired);
754 }
755
756 #[test]
757 fn bound_cap_within_limit_resumes_and_banks_ttl() {
758 let mut s = open_session(10_000);
759 s.max_suspend_ms = 500;
760 s.suspend(0).unwrap();
761 s.resume(400).unwrap();
762 assert_eq!(s.state, SessionState::Open);
763 assert_eq!(s.ttl_expiry, 10_400);
764 }
765
766 #[test]
767 fn suspend_cap_exceeded_uses_bound_cap() {
768 let mut s = open_session(10_000);
769 s.max_suspend_ms = 500;
770 s.suspend(0).unwrap();
771 assert!(!s.suspend_cap_exceeded(400));
772 assert!(s.suspend_cap_exceeded(501));
773 }
774
775 #[test]
776 fn unbound_session_uses_default_cap() {
777 let s = open_session(10_000);
778 assert_eq!(s.max_suspend_ms, 0);
779 assert_eq!(s.effective_max_suspend_ms(), MAX_SUSPEND_MS);
780 }
781
782 #[test]
783 fn negative_max_suspend_ms_rejected_in_canonical_payload() {
784 let payload = SessionStartPayload {
785 participants: vec!["a".into()],
786 mode_version: "1.0.0".into(),
787 configuration_version: "cfg-1".into(),
788 ttl_ms: 60_000,
789 max_suspend_ms: -1,
790 ..Default::default()
791 };
792 assert_eq!(
793 validate_canonical_session_start_payload(&payload)
794 .unwrap_err()
795 .to_string(),
796 "InvalidPayload"
797 );
798 let ok0 = SessionStartPayload {
800 max_suspend_ms: 0,
801 ..payload.clone()
802 };
803 validate_canonical_session_start_payload(&ok0).unwrap();
804 let ok_pos = SessionStartPayload {
805 max_suspend_ms: 60_000,
806 ..payload
807 };
808 validate_canonical_session_start_payload(&ok_pos).unwrap();
809 }
810
811 #[test]
812 fn cancel_from_open_or_suspended_then_terminal_is_rejected() {
813 let mut s = open_session(10_000);
814 s.suspend(1).unwrap();
815 s.cancel().unwrap();
816 assert_eq!(s.state, SessionState::Cancelled);
817 assert_eq!(s.suspended_at_ms, None);
818 assert!(matches!(s.cancel().unwrap_err(), MacpError::SessionNotOpen));
820
821 let mut open = open_session(10_000);
822 open.cancel().unwrap();
823 assert_eq!(open.state, SessionState::Cancelled);
824 }
825
826 #[test]
827 fn standard_mode_rejects_duplicate_participants() {
828 let payload = SessionStartPayload {
829 participants: vec!["alice".into(), "alice".into()],
830 mode_version: "1.0.0".into(),
831 configuration_version: "cfg-1".into(),
832 ttl_ms: 1000,
833 ..Default::default()
834 };
835 assert_eq!(
836 validate_strict_session_start_payload("macp.mode.proposal.v1", &payload)
837 .unwrap_err()
838 .to_string(),
839 "InvalidPayload"
840 );
841 }
842
843 #[test]
844 fn multi_round_requires_strict_session_start() {
845 let payload = SessionStartPayload::default();
846 assert!(validate_strict_session_start_payload("ext.multi_round.v1", &payload).is_err());
847 }
848
849 #[test]
850 fn valid_uuid_v4_accepted() {
851 let id = uuid::Uuid::new_v4().as_hyphenated().to_string();
852 validate_session_id_for_acceptance(&id).unwrap();
853 }
854
855 #[test]
856 fn valid_base64url_accepted() {
857 validate_session_id_for_acceptance("abcdefghijklmnopqrstuv").unwrap();
859 validate_session_id_for_acceptance("abc-def_ghi-jkl_mno-pqr").unwrap();
861 }
862
863 #[test]
864 fn empty_id_rejected() {
865 assert_eq!(
866 validate_session_id_for_acceptance("")
867 .unwrap_err()
868 .to_string(),
869 "InvalidSessionId"
870 );
871 }
872
873 #[test]
874 fn short_weak_id_rejected() {
875 assert_eq!(
876 validate_session_id_for_acceptance("s1")
877 .unwrap_err()
878 .to_string(),
879 "InvalidSessionId"
880 );
881 assert_eq!(
882 validate_session_id_for_acceptance("decision-demo-1")
883 .unwrap_err()
884 .to_string(),
885 "InvalidSessionId"
886 );
887 }
888
889 #[test]
890 fn uppercase_uuid_rejected() {
891 let id = uuid::Uuid::new_v4()
892 .as_hyphenated()
893 .to_string()
894 .to_uppercase();
895 assert_eq!(
896 validate_session_id_for_acceptance(&id)
897 .unwrap_err()
898 .to_string(),
899 "InvalidSessionId"
900 );
901 }
902
903 #[test]
904 fn base64url_36_chars_with_hyphen_accepted() {
905 let id = "Zx-abcdefghijklmnopqrstuvwxyz_ABCDE-";
910 assert_eq!(id.len(), 36);
911 assert!(uuid::Uuid::parse_str(id).is_err());
912 validate_session_id_for_acceptance(id).unwrap();
913 }
914
915 #[test]
916 fn uuid_shaped_but_wrong_version_does_not_fall_through() {
917 let v4 = uuid::Uuid::new_v4();
920 let mut bytes = *v4.as_bytes();
921 bytes[6] = (bytes[6] & 0x0F) | 0x10;
922 bytes[8] = (bytes[8] & 0x3F) | 0x80;
923 let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
924 assert!(validate_session_id_for_acceptance(&v1_id).is_err());
925 }
926
927 #[test]
928 fn base64url_too_short_rejected() {
929 assert_eq!(
930 validate_session_id_for_acceptance("abcdefghij")
931 .unwrap_err()
932 .to_string(),
933 "InvalidSessionId"
934 );
935 }
936
937 #[test]
938 fn valid_uuid_v7_accepted() {
939 let v4 = uuid::Uuid::new_v4();
941 let mut bytes = *v4.as_bytes();
942 bytes[6] = (bytes[6] & 0x0F) | 0x70;
944 bytes[8] = (bytes[8] & 0x3F) | 0x80;
946 let v7_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
947 assert!(validate_session_id_for_acceptance(&v7_id).is_ok());
948 }
949
950 #[test]
951 fn uuid_v1_rejected() {
952 let v4 = uuid::Uuid::new_v4();
954 let mut bytes = *v4.as_bytes();
955 bytes[6] = (bytes[6] & 0x0F) | 0x10;
957 bytes[8] = (bytes[8] & 0x3F) | 0x80;
959 let v1_id = uuid::Uuid::from_bytes(bytes).as_hyphenated().to_string();
960 assert_eq!(
961 validate_session_id_for_acceptance(&v1_id)
962 .unwrap_err()
963 .to_string(),
964 "InvalidSessionId"
965 );
966 }
967
968 #[test]
969 fn too_many_participants_rejected() {
970 let participants: Vec<String> = (0..1001).map(|i| format!("agent://p{i}")).collect();
971 let bytes = encode_payload(5000, participants);
972 let payload = parse_session_start_payload(&bytes).unwrap();
973 assert_eq!(
974 validate_canonical_session_start_payload(&payload)
975 .unwrap_err()
976 .to_string(),
977 "InvalidPayload"
978 );
979 }
980
981 #[test]
982 fn max_participants_accepted() {
983 let participants: Vec<String> = (0..1000).map(|i| format!("agent://p{i}")).collect();
984 let bytes = encode_payload(5000, participants);
985 let payload = parse_session_start_payload(&bytes).unwrap();
986 validate_canonical_session_start_payload(&payload).unwrap();
987 }
988}