1use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize};
23
24pub const MESSAGE_ID_PREFIX: &str = "m4a_";
28
29pub const MESSAGE_ID_HEX_LEN: usize = 24;
32
33pub const SELECTOR_MAX_BYTES: usize = 128;
37
38pub const SUBJECT_MAX_BYTES: usize = 512;
41
42pub const BODY_MAX_BYTES: usize = 65_536;
45
46pub const REFS_MAX: usize = 8;
50
51pub const REF_LOCATOR_MAX_BYTES: usize = 512;
53
54pub const REF_DIGEST_MAX_CHARS: usize = 128;
59
60pub const INBOX_LIMIT_MAX: u16 = 256;
62
63pub const INBOX_LIMIT_DEFAULT: u16 = 50;
65
66pub const INBOX_WAIT_SECS_MAX: u16 = 60;
76
77fn default_inbox_limit() -> u16 {
78 INBOX_LIMIT_DEFAULT
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
91pub enum MailError {
92 UnknownParticipant { participant: ParticipantId },
94 UnknownRoom { room: RoomId },
96 UnknownSession { session: SessionId },
100 SessionAccountMismatch { session: SessionId, expected: ParticipantId, presented: ParticipantId },
106 UnknownMessage { message_id: MessageId },
108 NotAddressedToYou { message_id: MessageId },
111 PermissionDenied { need: String },
114 Malformed { field: String, reason: String },
116 TooLarge { field: String, limit: usize, actual: usize },
119 StoreUnavailable { operation: String },
125}
126
127impl fmt::Display for MailError {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::UnknownParticipant { participant } => {
131 write!(f, "unknown participant \"{participant}\"")
132 }
133 Self::UnknownRoom { room } => write!(f, "unknown room \"{room}\""),
134 Self::UnknownSession { session } => write!(f, "unknown session \"{session}\""),
135 Self::SessionAccountMismatch { session, expected, presented } => write!(
136 f,
137 "session \"{session}\" belongs to account \"{expected}\", not \"{presented}\""
138 ),
139 Self::UnknownMessage { message_id } => write!(f, "unknown message \"{message_id}\""),
140 Self::NotAddressedToYou { message_id } => {
141 write!(f, "message \"{message_id}\" is not addressed to you")
142 }
143 Self::PermissionDenied { need } => write!(f, "permission denied: need \"{need}\""),
144 Self::Malformed { field, reason } => {
145 write!(f, "field \"{field}\" is malformed: {reason}")
146 }
147 Self::TooLarge { field, limit, actual } => {
148 write!(f, "field \"{field}\" is too large: limit {limit}, actual {actual}")
149 }
150 Self::StoreUnavailable { operation } => {
151 write!(f, "the store could not complete \"{operation}\"")
152 }
153 }
154 }
155}
156
157impl std::error::Error for MailError {}
158
159fn validate_opaque_id(
164 label: &'static str,
165 value: &str,
166 prefix: &str,
167 min_hex_len: usize,
168 max_hex_len: usize,
169) -> Result<(), MailError> {
170 let Some(hex) = value.strip_prefix(prefix) else {
171 return Err(MailError::Malformed {
172 field: label.to_string(),
173 reason: format!("must start with \"{prefix}\""),
174 });
175 };
176 if hex.len() < min_hex_len
177 || hex.len() > max_hex_len
178 || !hex.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
179 {
180 let width = if min_hex_len == max_hex_len {
181 min_hex_len.to_string()
182 } else {
183 format!("{min_hex_len}..={max_hex_len}")
184 };
185 return Err(MailError::Malformed {
186 field: label.to_string(),
187 reason: format!("body after \"{prefix}\" must be {width} lowercase hex characters"),
188 });
189 }
190 Ok(())
191}
192
193fn validate_selector(label: &'static str, value: &str) -> Result<(), MailError> {
194 if value.is_empty() {
195 return Err(MailError::Malformed {
196 field: label.to_string(),
197 reason: "must not be empty".to_string(),
198 });
199 }
200 if value.len() > SELECTOR_MAX_BYTES {
201 return Err(MailError::TooLarge {
202 field: label.to_string(),
203 limit: SELECTOR_MAX_BYTES,
204 actual: value.len(),
205 });
206 }
207 if !value.is_ascii()
208 || !value
209 .bytes()
210 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@' | b'+'))
211 {
212 return Err(MailError::Malformed {
213 field: label.to_string(),
214 reason: "must be ASCII from the set [A-Za-z0-9._@+-]".to_string(),
215 });
216 }
217 Ok(())
218}
219
220fn validate_bounded_text(field: &'static str, value: &str) -> Result<(), MailError> {
228 if value.len() > SUBJECT_MAX_BYTES {
229 return Err(MailError::TooLarge {
230 field: field.to_string(),
231 limit: SUBJECT_MAX_BYTES,
232 actual: value.len(),
233 });
234 }
235 if value.chars().any(char::is_control) {
236 return Err(MailError::Malformed {
237 field: field.to_string(),
238 reason: "must not contain control characters".to_string(),
239 });
240 }
241 Ok(())
242}
243
244fn validate_subject(value: &str) -> Result<(), MailError> {
245 validate_bounded_text("subject", value)
246}
247
248fn validate_body(value: &str) -> Result<(), MailError> {
249 if value.len() > BODY_MAX_BYTES {
250 return Err(MailError::TooLarge {
251 field: "body".to_string(),
252 limit: BODY_MAX_BYTES,
253 actual: value.len(),
254 });
255 }
256 if value.chars().any(|character| character.is_control() && !matches!(character, '\n' | '\t')) {
257 return Err(MailError::Malformed {
258 field: "body".to_string(),
259 reason: "must not contain control characters other than newline and tab".to_string(),
260 });
261 }
262 Ok(())
263}
264
265fn validate_correlation(value: &str) -> Result<(), MailError> {
270 if value.len() > SUBJECT_MAX_BYTES {
271 return Err(MailError::TooLarge {
272 field: "correlation".to_string(),
273 limit: SUBJECT_MAX_BYTES,
274 actual: value.len(),
275 });
276 }
277 if value.is_empty() || value.chars().any(char::is_control) {
278 return Err(MailError::Malformed {
279 field: "correlation".to_string(),
280 reason: "must be non-empty and free of control characters".to_string(),
281 });
282 }
283 Ok(())
284}
285
286fn validate_refs(refs: &[MessageRef]) -> Result<(), MailError> {
287 if refs.len() > REFS_MAX {
288 return Err(MailError::TooLarge {
289 field: "refs".to_string(),
290 limit: REFS_MAX,
291 actual: refs.len(),
292 });
293 }
294 for reference in refs {
295 reference.validate()?;
296 }
297 Ok(())
298}
299
300macro_rules! opaque_id {
305 ($name:ident, $prefix:expr, $label:literal, $min_hex:expr, $max_hex:expr, $doc:expr) => {
306 #[doc = $doc]
307 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
308 #[serde(transparent)]
309 pub struct $name(String);
310
311 impl $name {
312 pub const PREFIX: &'static str = $prefix;
313
314 pub fn new(value: impl Into<String>) -> Result<Self, MailError> {
315 let value = value.into();
316 validate_opaque_id($label, &value, Self::PREFIX, $min_hex, $max_hex)?;
317 Ok(Self(value))
318 }
319
320 pub fn validate(&self) -> Result<(), MailError> {
321 validate_opaque_id($label, &self.0, Self::PREFIX, $min_hex, $max_hex)
322 }
323
324 pub fn as_str(&self) -> &str {
325 &self.0
326 }
327 }
328
329 impl fmt::Debug for $name {
330 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331 formatter.debug_tuple(stringify!($name)).field(&self.0).finish()
332 }
333 }
334
335 impl fmt::Display for $name {
336 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
337 formatter.write_str(&self.0)
338 }
339 }
340
341 impl std::str::FromStr for $name {
342 type Err = MailError;
343
344 fn from_str(value: &str) -> Result<Self, Self::Err> {
345 Self::new(value)
346 }
347 }
348
349 impl<'de> Deserialize<'de> for $name {
350 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
351 where
352 D: Deserializer<'de>,
353 {
354 let value = String::deserialize(deserializer)?;
355 Self::new(value).map_err(serde::de::Error::custom)
356 }
357 }
358 };
359}
360
361macro_rules! selector_id {
369 ($name:ident, $label:literal, $doc:expr) => {
370 #[doc = $doc]
371 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
372 #[serde(transparent)]
373 pub struct $name(String);
374
375 impl $name {
376 pub fn new(value: impl Into<String>) -> Result<Self, MailError> {
377 let value = value.into();
378 validate_selector($label, &value)?;
379 Ok(Self(value))
380 }
381
382 pub fn validate(&self) -> Result<(), MailError> {
383 validate_selector($label, &self.0)
384 }
385
386 pub fn as_str(&self) -> &str {
387 &self.0
388 }
389 }
390
391 impl fmt::Debug for $name {
392 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
393 formatter.debug_tuple(stringify!($name)).field(&self.0).finish()
394 }
395 }
396
397 impl fmt::Display for $name {
398 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399 formatter.write_str(&self.0)
400 }
401 }
402
403 impl std::str::FromStr for $name {
404 type Err = MailError;
405
406 fn from_str(value: &str) -> Result<Self, Self::Err> {
407 Self::new(value)
408 }
409 }
410
411 impl<'de> Deserialize<'de> for $name {
412 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
413 where
414 D: Deserializer<'de>,
415 {
416 let value = String::deserialize(deserializer)?;
417 Self::new(value).map_err(serde::de::Error::custom)
418 }
419 }
420 };
421}
422
423opaque_id!(
424 MessageId,
425 MESSAGE_ID_PREFIX,
426 "message id",
427 MESSAGE_ID_HEX_LEN,
428 MESSAGE_ID_HEX_LEN,
429 "Opaque, prefixed, fixed-width hex id for a stored [`Message`]. Ported \
430 from `HarnessMailMessageId` (prefix `hmail_` there, `m4a_` here so a \
431 value can never be mistaken for a harness message id from the crate \
432 this was ported out of)."
433);
434
435pub const SESSION_ID_PREFIX: &str = "s-";
439
440pub const SESSION_ID_HEX_MIN_CHARS: usize = 8;
445
446pub const SESSION_ID_HEX_MAX_CHARS: usize = 64;
454
455opaque_id!(
456 SessionId,
457 SESSION_ID_PREFIX,
458 "session id",
459 SESSION_ID_HEX_MIN_CHARS,
460 SESSION_ID_HEX_MAX_CHARS,
461 "Opaque id for one live session under a [`ParticipantId`] account. \
462 Derived by the caller from a process identity and handed to this \
463 crate already formed -- `MailboxEngine::ensure_session` registers one, \
464 it never invents one."
465);
466
467selector_id!(
468 ParticipantId,
469 "participant id",
470 "Addresses one participant directly. Distinct from [`RoomId`] on \
471 purpose: a room id must never be accepted where a participant id is \
472 meant, and a shared alias would let one slip into the other's slot."
473);
474
475selector_id!(
476 RoomId,
477 "room id",
478 "Addresses a named group of participants the mailbox itself tracks. \
479 Distinct from [`ParticipantId`] on purpose (see there)."
480);
481
482#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
495#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
496pub enum Address {
497 Direct { participant: ParticipantId },
498 Session { participant: ParticipantId, session: SessionId },
499 Room { room: RoomId },
500}
501
502impl Address {
503 pub fn validate(&self) -> Result<(), MailError> {
504 match self {
505 Self::Direct { participant } => participant.validate(),
506 Self::Session { participant, session } => {
507 participant.validate()?;
508 session.validate()
509 }
510 Self::Room { room } => room.validate(),
511 }
512 }
513
514 pub fn account(&self) -> Option<&ParticipantId> {
518 match self {
519 Self::Direct { participant } | Self::Session { participant, .. } => Some(participant),
520 Self::Room { .. } => None,
521 }
522 }
523}
524
525fn validate_participant_address(field: &'static str, address: &Address) -> Result<(), MailError> {
532 address.validate()?;
533 if address.account().is_none() {
534 return Err(MailError::Malformed {
535 field: field.to_string(),
536 reason: "must be a participant address (direct or session), not a room".to_string(),
537 });
538 }
539 Ok(())
540}
541
542impl fmt::Display for Address {
543 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551 match self {
552 Self::Direct { participant } => write!(f, "{participant}"),
553 Self::Session { participant, session } => write!(f, "{participant}/{session}"),
554 Self::Room { room } => write!(f, "#{room}"),
555 }
556 }
557}
558
559impl std::str::FromStr for Address {
560 type Err = MailError;
561
562 fn from_str(value: &str) -> Result<Self, Self::Err> {
570 if let Some(room) = value.strip_prefix('#') {
571 return Ok(Self::Room { room: RoomId::new(room)? });
572 }
573 match value.split_once('/') {
574 Some((participant, session)) => {
575 Ok(Self::Session { participant: ParticipantId::new(participant)?, session: SessionId::new(session)? })
576 }
577 None => Ok(Self::Direct { participant: ParticipantId::new(value)? }),
578 }
579 }
580}
581
582#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
595#[serde(deny_unknown_fields)]
596pub struct MessageRef {
597 pub kind: String,
598 pub locator: String,
599 pub digest: Option<String>,
600}
601
602impl MessageRef {
603 pub fn validate(&self) -> Result<(), MailError> {
604 validate_selector("ref kind", &self.kind)?;
605 if self.locator.len() > REF_LOCATOR_MAX_BYTES {
606 return Err(MailError::TooLarge {
607 field: "ref locator".to_string(),
608 limit: REF_LOCATOR_MAX_BYTES,
609 actual: self.locator.len(),
610 });
611 }
612 if self.locator.is_empty() || self.locator.chars().any(char::is_control) {
613 return Err(MailError::Malformed {
614 field: "ref locator".to_string(),
615 reason: "must be non-empty and free of control characters".to_string(),
616 });
617 }
618 if let Some(digest) = &self.digest {
619 if digest.len() > REF_DIGEST_MAX_CHARS {
620 return Err(MailError::TooLarge {
621 field: "ref digest".to_string(),
622 limit: REF_DIGEST_MAX_CHARS,
623 actual: digest.len(),
624 });
625 }
626 if digest.is_empty()
627 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
628 {
629 return Err(MailError::Malformed {
630 field: "ref digest".to_string(),
631 reason: "must be non-empty lowercase hex".to_string(),
632 });
633 }
634 }
635 Ok(())
636 }
637}
638
639#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct Message {
647 pub message_id: MessageId,
648 pub from: Address,
652 pub to: Address,
653 pub subject: String,
654 pub body: String,
655 pub reply_to: Option<MessageId>,
656 pub correlation: Option<String>,
662 #[serde(default)]
663 pub refs: Vec<MessageRef>,
664 pub created_at_unix_ms: u64,
665}
666
667impl Message {
668 pub fn validate(&self) -> Result<(), MailError> {
669 self.message_id.validate()?;
670 validate_participant_address("from", &self.from)?;
671 self.to.validate()?;
672 validate_subject(&self.subject)?;
673 validate_body(&self.body)?;
674 if let Some(reply_to) = &self.reply_to {
675 reply_to.validate()?;
676 if reply_to == &self.message_id {
677 return Err(MailError::Malformed {
678 field: "reply_to".to_string(),
679 reason: "must not reference its own message_id".to_string(),
680 });
681 }
682 }
683 if let Some(correlation) = &self.correlation {
684 validate_correlation(correlation)?;
685 }
686 validate_refs(&self.refs)?;
687 if self.created_at_unix_ms == 0 {
688 return Err(MailError::Malformed {
689 field: "created_at_unix_ms".to_string(),
690 reason: "must not be zero".to_string(),
691 });
692 }
693 Ok(())
694 }
695}
696
697#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
701#[serde(deny_unknown_fields)]
702pub struct Ack {
703 pub message_id: MessageId,
704 pub reader: Address,
710 pub acked_at_unix_ms: u64,
711}
712
713impl Ack {
714 pub fn validate(&self) -> Result<(), MailError> {
715 self.message_id.validate()?;
716 validate_participant_address("reader", &self.reader)?;
717 if self.acked_at_unix_ms == 0 {
718 return Err(MailError::Malformed {
719 field: "acked_at_unix_ms".to_string(),
720 reason: "must not be zero".to_string(),
721 });
722 }
723 Ok(())
724 }
725}
726
727#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
738#[serde(transparent)]
739pub struct Declared<T>(T);
740
741impl<T> Declared<T> {
742 pub fn new(value: T) -> Self {
743 Self(value)
744 }
745
746 pub fn into_inner(self) -> T {
748 self.0
749 }
750
751 pub fn inner_ref(&self) -> &T {
756 &self.0
757 }
758}
759
760impl<T: fmt::Display> fmt::Display for Declared<T> {
761 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762 fmt::Display::fmt(&self.0, f)
763 }
764}
765
766#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct SessionAttested {
775 pub pid: u32,
776 pub started_at_unix_ms: u64,
777 pub exe: Option<String>,
778}
779
780impl SessionAttested {
781 pub fn validate(&self) -> Result<(), MailError> {
782 if let Some(exe) = &self.exe {
783 validate_bounded_text("attested exe", exe)?;
784 }
785 Ok(())
786 }
787}
788
789#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct SessionCorroborated {
797 pub provider_session_id: Option<Declared<String>>,
798 pub model: Option<Declared<String>>,
799 pub cwd: Option<Declared<String>>,
800}
801
802impl SessionCorroborated {
803 pub fn validate(&self) -> Result<(), MailError> {
804 if let Some(value) = &self.provider_session_id {
805 validate_bounded_text("corroborated provider_session_id", value.inner_ref())?;
806 }
807 if let Some(value) = &self.model {
808 validate_bounded_text("corroborated model", value.inner_ref())?;
809 }
810 if let Some(value) = &self.cwd {
811 validate_bounded_text("corroborated cwd", value.inner_ref())?;
812 }
813 Ok(())
814 }
815}
816
817#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
821#[serde(deny_unknown_fields)]
822pub struct SessionDeclared {
823 pub working_on: Option<String>,
824 pub role: Option<String>,
825 pub parent: Option<SessionId>,
829}
830
831impl SessionDeclared {
832 pub fn validate(&self) -> Result<(), MailError> {
833 if let Some(value) = &self.working_on {
834 validate_bounded_text("declared working_on", value)?;
835 }
836 if let Some(value) = &self.role {
837 validate_bounded_text("declared role", value)?;
838 }
839 if let Some(parent) = &self.parent {
840 parent.validate()?;
841 }
842 Ok(())
843 }
844}
845
846#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
855#[serde(deny_unknown_fields)]
856pub struct SessionCard {
857 pub attested: SessionAttested,
858 pub corroborated: SessionCorroborated,
859 pub declared: SessionDeclared,
860}
861
862impl SessionCard {
863 pub fn validate(&self) -> Result<(), MailError> {
864 self.attested.validate()?;
865 self.corroborated.validate()?;
866 self.declared.validate()
867 }
868}
869
870#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
875#[serde(deny_unknown_fields)]
876pub struct SessionEntry {
877 pub id: SessionId,
878 pub card: SessionCard,
879 pub last_seen_unix_ms: u64,
880 pub live: bool,
881}
882
883impl SessionEntry {
884 pub fn validate(&self) -> Result<(), MailError> {
885 self.id.validate()?;
886 self.card.validate()
887 }
888}
889
890#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct Participant {
897 pub id: ParticipantId,
898 pub label: Option<String>,
899}
900
901impl Participant {
902 pub fn validate(&self) -> Result<(), MailError> {
903 self.id.validate()?;
904 if let Some(label) = &self.label {
905 validate_label(label)?;
906 }
907 Ok(())
908 }
909}
910
911fn validate_label(value: &str) -> Result<(), MailError> {
918 validate_bounded_text("label", value)
919}
920
921#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
933#[serde(deny_unknown_fields)]
934pub struct DirectoryEntry {
935 pub id: ParticipantId,
936 pub label: Option<String>,
937 #[serde(default)]
938 pub sessions: Vec<SessionEntry>,
939}
940
941impl DirectoryEntry {
942 pub fn validate(&self) -> Result<(), MailError> {
943 self.id.validate()?;
944 if let Some(label) = &self.label {
945 validate_label(label)?;
946 }
947 for session in &self.sessions {
948 session.validate()?;
949 }
950 Ok(())
951 }
952}
953
954#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
960#[serde(deny_unknown_fields)]
961pub struct RoomEntry {
962 pub id: RoomId,
963 pub member: bool,
964}
965
966impl RoomEntry {
967 pub fn validate(&self) -> Result<(), MailError> {
968 self.id.validate()
969 }
970}
971
972#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
978#[serde(deny_unknown_fields)]
979pub struct Directory {
980 pub participants: Vec<DirectoryEntry>,
981 pub rooms: Vec<RoomEntry>,
982}
983
984impl Directory {
985 pub fn validate(&self) -> Result<(), MailError> {
986 for participant in &self.participants {
987 participant.validate()?;
988 }
989 for room in &self.rooms {
990 room.validate()?;
991 }
992 Ok(())
993 }
994}
995
996#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1001#[serde(deny_unknown_fields)]
1002pub struct SendRequest {
1003 pub to: Address,
1004 pub subject: String,
1005 pub body: String,
1006 pub reply_to: Option<MessageId>,
1007 pub correlation: Option<String>,
1008 #[serde(default)]
1009 pub refs: Vec<MessageRef>,
1010 #[serde(default)]
1021 pub idempotency_key: Option<String>,
1022}
1023
1024impl SendRequest {
1025 pub fn validate(&self) -> Result<(), MailError> {
1026 self.to.validate()?;
1027 validate_subject(&self.subject)?;
1028 validate_body(&self.body)?;
1029 if let Some(reply_to) = &self.reply_to {
1030 reply_to.validate()?;
1031 }
1032 if let Some(correlation) = &self.correlation {
1033 validate_correlation(correlation)?;
1034 }
1035 validate_refs(&self.refs)?;
1036 if let Some(idempotency_key) = &self.idempotency_key {
1037 validate_selector("idempotency_key", idempotency_key)?;
1038 }
1039 Ok(())
1040 }
1041}
1042
1043#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1047#[serde(deny_unknown_fields)]
1048pub struct InboxRequest {
1049 pub since_unix_ms: Option<u64>,
1050 #[serde(default = "default_inbox_limit")]
1051 pub limit: u16,
1052 #[serde(default)]
1063 pub wait_secs: Option<u16>,
1064}
1065
1066impl InboxRequest {
1067 pub fn validate(&self) -> Result<(), MailError> {
1068 if self.limit == 0 {
1069 return Err(MailError::Malformed {
1070 field: "limit".to_string(),
1071 reason: "must be at least 1".to_string(),
1072 });
1073 }
1074 if self.limit > INBOX_LIMIT_MAX {
1075 return Err(MailError::TooLarge {
1076 field: "limit".to_string(),
1077 limit: usize::from(INBOX_LIMIT_MAX),
1078 actual: usize::from(self.limit),
1079 });
1080 }
1081 Ok(())
1082 }
1083}
1084
1085#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1089#[serde(deny_unknown_fields)]
1090pub struct AckRequest {
1091 pub message_id: MessageId,
1092}
1093
1094impl AckRequest {
1095 pub fn validate(&self) -> Result<(), MailError> {
1096 self.message_id.validate()
1097 }
1098}
1099
1100#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1102#[serde(deny_unknown_fields)]
1103pub struct MessageGetRequest {
1104 pub message_id: MessageId,
1105}
1106
1107impl MessageGetRequest {
1108 pub fn validate(&self) -> Result<(), MailError> {
1109 self.message_id.validate()
1110 }
1111}
1112
1113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1116#[serde(deny_unknown_fields)]
1117pub struct UnreadCountRequest {
1118 pub target: Address,
1119}
1120
1121impl UnreadCountRequest {
1122 pub fn validate(&self) -> Result<(), MailError> {
1123 validate_participant_address("target", &self.target)
1124 }
1125}
1126
1127#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131pub struct SendResponse {
1132 pub message_id: MessageId,
1133 pub from: Address,
1134}
1135
1136impl SendResponse {
1137 pub fn validate(&self) -> Result<(), MailError> {
1138 self.message_id.validate()?;
1139 validate_participant_address("from", &self.from)
1140 }
1141}
1142
1143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1145#[serde(deny_unknown_fields)]
1146pub struct InboxPage {
1147 pub messages: Vec<Message>,
1148 pub unread: u32,
1149}
1150
1151impl InboxPage {
1152 pub fn validate(&self) -> Result<(), MailError> {
1153 for message in &self.messages {
1154 message.validate()?;
1155 }
1156 Ok(())
1157 }
1158}
1159
1160#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1164#[serde(deny_unknown_fields)]
1165pub struct AckResponse {
1166 pub ack: Ack,
1167}
1168
1169impl AckResponse {
1170 pub fn validate(&self) -> Result<(), MailError> {
1171 self.ack.validate()
1172 }
1173}
1174
1175#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1177#[serde(deny_unknown_fields)]
1178pub struct UnreadCount {
1179 pub target: Address,
1180 pub unread: u32,
1181}
1182
1183impl UnreadCount {
1184 pub fn validate(&self) -> Result<(), MailError> {
1185 validate_participant_address("target", &self.target)
1186 }
1187}
1188
1189#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1203#[serde(deny_unknown_fields)]
1204pub struct DeliveryNotification {
1205 pub account: ParticipantId,
1209 pub to: Address,
1214 pub message_id: MessageId,
1215 pub from: Address,
1216}
1217
1218impl DeliveryNotification {
1219 pub fn validate(&self) -> Result<(), MailError> {
1220 self.account.validate()?;
1221 self.to.validate()?;
1222 self.message_id.validate()?;
1223 self.from.validate()
1224 }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230
1231 const VALID_MESSAGE_ID: &str = "m4a_0123456789abcdef01234567";
1232
1233 fn participant(value: &str) -> ParticipantId {
1234 ParticipantId::new(value).expect("test participant id is valid")
1235 }
1236
1237 fn message_id() -> MessageId {
1238 MessageId::new(VALID_MESSAGE_ID).expect("test message id is valid")
1239 }
1240
1241 fn session_id(value: &str) -> SessionId {
1242 SessionId::new(value).expect("test session id is valid")
1243 }
1244
1245 fn sample_message() -> Message {
1246 Message {
1247 message_id: message_id(),
1248 from: Address::Direct { participant: participant("alice") },
1249 to: Address::Direct { participant: participant("bob") },
1250 subject: "hi".to_string(),
1251 body: "hi".to_string(),
1252 reply_to: None,
1253 correlation: None,
1254 refs: Vec::new(),
1255 created_at_unix_ms: 1,
1256 }
1257 }
1258
1259 #[test]
1260 fn message_id_round_trips_through_display_from_str_and_serde() {
1261 let id: MessageId = VALID_MESSAGE_ID.parse().expect("valid id parses");
1262 assert_eq!(id.to_string(), VALID_MESSAGE_ID);
1263 assert_eq!(id.as_str(), VALID_MESSAGE_ID);
1264
1265 let json = serde_json::to_string(&id).expect("id serializes");
1266 assert_eq!(json, format!("\"{VALID_MESSAGE_ID}\""));
1267 let decoded: MessageId = serde_json::from_str(&json).expect("id deserializes");
1268 assert_eq!(decoded, id);
1269 }
1270
1271 #[test]
1272 fn message_id_rejects_wrong_prefix() {
1273 let err = MessageId::new("wrong_0123456789abcdef01234567").expect_err("wrong prefix must be rejected");
1274 assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1275 }
1276
1277 #[test]
1278 fn message_id_rejects_short_body() {
1279 let err = MessageId::new("m4a_0123456789abcdef").expect_err("short body must be rejected");
1280 assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1281 }
1282
1283 #[test]
1284 fn message_id_rejects_non_hex_body() {
1285 let err = MessageId::new("m4a_0123456789abcdef0123456g").expect_err("non-hex body must be rejected");
1286 assert!(matches!(err, MailError::Malformed { field, .. } if field == "message id"));
1287 }
1288
1289 #[test]
1290 fn participant_id_rejects_empty() {
1291 let err = ParticipantId::new("").expect_err("empty selector must be rejected");
1292 assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1293 }
1294
1295 #[test]
1296 fn participant_id_rejects_129_bytes() {
1297 let value = "a".repeat(129);
1298 let err = ParticipantId::new(value).expect_err("129-byte selector must be rejected");
1299 assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1300 if field == "participant id" && limit == SELECTOR_MAX_BYTES && actual == 129));
1301 }
1302
1303 #[test]
1304 fn participant_id_accepts_exactly_128_bytes() {
1305 let value = "a".repeat(SELECTOR_MAX_BYTES);
1306 ParticipantId::new(value).expect("128-byte selector is accepted");
1307 }
1308
1309 #[test]
1310 fn participant_id_rejects_non_ascii() {
1311 let err = ParticipantId::new("héllo").expect_err("non-ASCII selector must be rejected");
1312 assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1313 }
1314
1315 #[test]
1316 fn participant_id_rejects_disallowed_characters() {
1317 for value in ["a/b", "a b"] {
1318 let err = ParticipantId::new(value).expect_err("disallowed character must be rejected");
1319 assert!(matches!(err, MailError::Malformed { field, .. } if field == "participant id"));
1320 }
1321 }
1322
1323 #[test]
1324 fn room_id_and_participant_id_are_distinct_types() {
1325 let room = RoomId::new("shared-name").expect("valid room id");
1333 let participant = ParticipantId::new("shared-name").expect("valid participant id");
1334 assert_eq!(room.as_str(), participant.as_str());
1335 }
1336
1337 #[test]
1338 fn subject_is_accepted_at_exactly_the_byte_limit() {
1339 let subject = "a".repeat(SUBJECT_MAX_BYTES);
1340 validate_subject(&subject).expect("exactly at the limit is accepted");
1341 }
1342
1343 #[test]
1344 fn subject_is_rejected_one_byte_over_the_limit() {
1345 let subject = "a".repeat(SUBJECT_MAX_BYTES + 1);
1346 let err = validate_subject(&subject).expect_err("one byte over the limit must be rejected");
1347 assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1348 if field == "subject" && limit == SUBJECT_MAX_BYTES && actual == SUBJECT_MAX_BYTES + 1));
1349 }
1350
1351 #[test]
1352 fn subject_byte_limit_counts_bytes_not_chars_for_multi_byte_utf8() {
1353 let subject: String = "\u{1F600}".repeat(200);
1355 assert!(subject.chars().count() < SUBJECT_MAX_BYTES, "under the byte bound counted as chars");
1356 assert!(subject.len() > SUBJECT_MAX_BYTES, "over the byte bound counted as bytes");
1357 let err = validate_subject(&subject).expect_err("byte length must govern, not char count");
1358 assert!(matches!(err, MailError::TooLarge { field, .. } if field == "subject"));
1359 }
1360
1361 #[test]
1362 fn body_is_accepted_at_exactly_the_byte_limit() {
1363 let body = "a".repeat(BODY_MAX_BYTES);
1364 validate_body(&body).expect("exactly at the limit is accepted");
1365 }
1366
1367 #[test]
1368 fn body_is_rejected_one_byte_over_the_limit() {
1369 let body = "a".repeat(BODY_MAX_BYTES + 1);
1370 let err = validate_body(&body).expect_err("one byte over the limit must be rejected");
1371 assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1372 if field == "body" && limit == BODY_MAX_BYTES && actual == BODY_MAX_BYTES + 1));
1373 }
1374
1375 #[test]
1376 fn body_byte_limit_counts_bytes_not_chars_for_multi_byte_utf8() {
1377 let body: String = "\u{1F600}".repeat(20_000);
1378 assert!(body.chars().count() < BODY_MAX_BYTES, "under the byte bound counted as chars");
1379 assert!(body.len() > BODY_MAX_BYTES, "over the byte bound counted as bytes");
1380 let err = validate_body(&body).expect_err("byte length must govern, not char count");
1381 assert!(matches!(err, MailError::TooLarge { field, .. } if field == "body"));
1382 }
1383
1384 #[test]
1385 fn message_rejects_reply_to_referencing_its_own_message_id() {
1386 let mut message = sample_message();
1387 message.reply_to = Some(message.message_id.clone());
1388 let err = message.validate().expect_err("self reply_to must be rejected");
1389 assert!(matches!(err, MailError::Malformed { field, .. } if field == "reply_to"));
1390 }
1391
1392 #[test]
1393 fn message_rejects_more_refs_than_the_bound() {
1394 let mut message = sample_message();
1395 message.refs = (0..=REFS_MAX)
1396 .map(|index| MessageRef {
1397 kind: "note".to_string(),
1398 locator: format!("loc-{index}"),
1399 digest: None,
1400 })
1401 .collect();
1402 let err = message.validate().expect_err("refs over the bound must be rejected");
1403 assert!(matches!(err, MailError::TooLarge { field, limit, actual }
1404 if field == "refs" && limit == REFS_MAX && actual == REFS_MAX + 1));
1405 }
1406
1407 #[test]
1408 fn message_accepts_exactly_refs_max_refs() {
1409 let mut message = sample_message();
1410 message.refs = (0..REFS_MAX)
1411 .map(|index| MessageRef {
1412 kind: "note".to_string(),
1413 locator: format!("loc-{index}"),
1414 digest: None,
1415 })
1416 .collect();
1417 message.validate().expect("exactly the bound is accepted");
1418 }
1419
1420 #[test]
1421 fn message_json_without_refs_key_still_deserializes() {
1422 let json = format!(
1423 r#"{{
1424 "message_id": "{VALID_MESSAGE_ID}",
1425 "from": {{"kind": "direct", "participant": "alice"}},
1426 "to": {{"kind": "direct", "participant": "bob"}},
1427 "subject": "hi",
1428 "body": "hi",
1429 "reply_to": null,
1430 "correlation": null,
1431 "created_at_unix_ms": 1
1432 }}"#
1433 );
1434 let message: Message = serde_json::from_str(&json).expect("old shape without refs must still deserialize");
1435 assert!(message.refs.is_empty());
1436 message.validate().expect("decoded message is otherwise valid");
1437 }
1438
1439 #[test]
1440 fn address_serialises_to_the_tagged_kind_form_and_round_trips() {
1441 let direct = Address::Direct { participant: participant("alice") };
1442 let json = serde_json::to_value(&direct).expect("direct address serializes");
1443 assert_eq!(json, serde_json::json!({"kind": "direct", "participant": "alice"}));
1444 let decoded: Address = serde_json::from_value(json).expect("direct address deserializes");
1445 assert_eq!(decoded, direct);
1446
1447 let session = Address::Session { participant: participant("claude"), session: session_id("s-7f3a0000") };
1448 let json = serde_json::to_value(&session).expect("session address serializes");
1449 assert_eq!(
1450 json,
1451 serde_json::json!({"kind": "session", "participant": "claude", "session": "s-7f3a0000"})
1452 );
1453 let decoded: Address = serde_json::from_value(json).expect("session address deserializes");
1454 assert_eq!(decoded, session);
1455
1456 let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1457 let json = serde_json::to_value(&room).expect("room address serializes");
1458 assert_eq!(json, serde_json::json!({"kind": "room", "room": "room-1"}));
1459 let decoded: Address = serde_json::from_value(json).expect("room address deserializes");
1460 assert_eq!(decoded, room);
1461 }
1462
1463 #[test]
1464 fn address_display_and_from_str_use_the_familiar_shape() {
1465 let direct = Address::Direct { participant: participant("claude") };
1466 assert_eq!(direct.to_string(), "claude");
1467 assert_eq!("claude".parse::<Address>().expect("direct address parses"), direct);
1468
1469 let session = Address::Session { participant: participant("claude"), session: session_id("s-7f3a0000") };
1470 assert_eq!(session.to_string(), "claude/s-7f3a0000");
1471 assert_eq!("claude/s-7f3a0000".parse::<Address>().expect("session address parses"), session);
1472
1473 let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1474 assert_eq!(room.to_string(), "#room-1");
1475 assert_eq!("#room-1".parse::<Address>().expect("room address parses"), room);
1476 }
1477
1478 #[test]
1479 fn address_account_names_the_owning_account_and_none_for_a_room() {
1480 let alice = participant("alice");
1481 assert_eq!(Address::Direct { participant: alice.clone() }.account(), Some(&alice));
1482 assert_eq!(
1483 Address::Session { participant: alice.clone(), session: session_id("s-7f3a0000") }.account(),
1484 Some(&alice)
1485 );
1486 assert_eq!(Address::Room { room: RoomId::new("room-1").expect("valid room id") }.account(), None);
1487 }
1488
1489 #[test]
1490 fn message_and_ack_and_send_response_refuse_a_room_as_the_participant_address() {
1491 let room = Address::Room { room: RoomId::new("room-1").expect("valid room id") };
1492
1493 let mut message = sample_message();
1494 message.from = room.clone();
1495 let err = message.validate().expect_err("a room must not be accepted as `from`");
1496 assert!(matches!(err, MailError::Malformed { field, .. } if field == "from"));
1497
1498 let ack = Ack { message_id: message_id(), reader: room.clone(), acked_at_unix_ms: 1 };
1499 let err = ack.validate().expect_err("a room must not be accepted as `reader`");
1500 assert!(matches!(err, MailError::Malformed { field, .. } if field == "reader"));
1501
1502 let response = SendResponse { message_id: message_id(), from: room };
1503 let err = response.validate().expect_err("a room must not be accepted as `from`");
1504 assert!(matches!(err, MailError::Malformed { field, .. } if field == "from"));
1505 }
1506
1507 #[test]
1508 fn session_id_round_trips_and_rejects_a_short_body() {
1509 let id: SessionId = "s-7f3a0000".parse().expect("valid session id parses");
1510 assert_eq!(id.to_string(), "s-7f3a0000");
1511
1512 let err = SessionId::new("s-abc").expect_err("a body shorter than the minimum must be rejected");
1513 assert!(matches!(err, MailError::Malformed { field, .. } if field == "session id"));
1514
1515 let err = SessionId::new("wrong-7f3a0000").expect_err("a wrong prefix must be rejected");
1516 assert!(matches!(err, MailError::Malformed { field, .. } if field == "session id"));
1517 }
1518
1519 #[test]
1520 fn session_card_validates_every_group_and_rejects_a_control_character_anywhere() {
1521 let mut card = SessionCard {
1522 attested: SessionAttested { pid: 4242, started_at_unix_ms: 1, exe: Some("claude.exe".to_string()) },
1523 corroborated: SessionCorroborated {
1524 provider_session_id: Some(Declared::new("prov-1".to_string())),
1525 model: Some(Declared::new("opus".to_string())),
1526 cwd: None,
1527 },
1528 declared: SessionDeclared { working_on: Some("parity work".to_string()), role: None, parent: None },
1529 };
1530 card.validate().expect("a well-formed card validates");
1531
1532 card.corroborated.model = Some(Declared::new("bad\u{0007}model".to_string()));
1533 let err = card.validate().expect_err("a control character in a corroborated field must be rejected");
1534 assert!(matches!(err, MailError::Malformed { field, .. } if field == "corroborated model"));
1535 }
1536
1537 #[test]
1538 fn directory_entry_nests_its_sessions_and_defaults_to_none_on_decode() {
1539 let entry = DirectoryEntry {
1540 id: participant("claude"),
1541 label: None,
1542 sessions: vec![SessionEntry {
1543 id: session_id("s-7f3a0000"),
1544 card: SessionCard {
1545 attested: SessionAttested { pid: 1, started_at_unix_ms: 1, exe: None },
1546 corroborated: SessionCorroborated { provider_session_id: None, model: None, cwd: None },
1547 declared: SessionDeclared::default(),
1548 },
1549 last_seen_unix_ms: 1,
1550 live: true,
1551 }],
1552 };
1553 entry.validate().expect("a well-formed entry with a session validates");
1554
1555 let json = serde_json::json!({"id": "claude", "label": null});
1556 let decoded: DirectoryEntry = serde_json::from_value(json).expect("an entry without sessions still decodes");
1557 assert!(decoded.sessions.is_empty());
1558 }
1559
1560 #[test]
1561 fn inbox_request_defaults_limit_when_json_omits_it() {
1562 let request: InboxRequest = serde_json::from_str(r#"{"since_unix_ms": null}"#)
1563 .expect("inbox request without limit still deserializes");
1564 assert_eq!(request.limit, INBOX_LIMIT_DEFAULT);
1565 request.validate().expect("default limit is valid");
1566 }
1567
1568 #[test]
1569 fn inbox_request_rejects_limit_over_the_max() {
1570 let request = InboxRequest { since_unix_ms: None, limit: INBOX_LIMIT_MAX + 1, wait_secs: None };
1571 let err = request.validate().expect_err("limit over the max must be rejected");
1572 assert!(matches!(err, MailError::TooLarge { field, .. } if field == "limit"));
1573 }
1574
1575 #[test]
1576 fn inbox_request_rejects_zero_limit() {
1577 let request = InboxRequest { since_unix_ms: None, limit: 0, wait_secs: None };
1578 let err = request.validate().expect_err("zero limit must be rejected");
1579 assert!(matches!(err, MailError::Malformed { field, .. } if field == "limit"));
1580 }
1581
1582 #[test]
1583 fn inbox_request_defaults_wait_secs_to_none_when_json_omits_it() {
1584 let request: InboxRequest = serde_json::from_str(r#"{"since_unix_ms": null}"#)
1585 .expect("inbox request without wait_secs still deserializes");
1586 assert_eq!(request.wait_secs, None);
1587 }
1588
1589 #[test]
1590 fn inbox_request_accepts_wait_secs_over_the_cap_without_refusing() {
1591 let request =
1594 InboxRequest { since_unix_ms: None, limit: INBOX_LIMIT_DEFAULT, wait_secs: Some(INBOX_WAIT_SECS_MAX + 1) };
1595 request.validate().expect("wait_secs over the cap is not a validation failure");
1596 }
1597
1598 #[test]
1599 fn delivery_notification_round_trips_and_never_carries_a_subject_or_body_field() {
1600 let notification = DeliveryNotification {
1601 account: participant("alice"),
1602 to: Address::Direct { participant: participant("alice") },
1603 message_id: message_id(),
1604 from: Address::Direct { participant: participant("bob") },
1605 };
1606 notification.validate().expect("a well-formed notification validates");
1607
1608 let json = serde_json::to_value(¬ification).expect("notification serializes");
1609 assert!(json.get("subject").is_none());
1610 assert!(json.get("body").is_none());
1611 let decoded: DeliveryNotification =
1612 serde_json::from_value(json).expect("notification round-trips through serde");
1613 assert_eq!(decoded, notification);
1614 }
1615
1616 #[test]
1617 fn message_ref_rejects_more_than_the_digest_bound() {
1618 let reference = MessageRef {
1619 kind: "note".to_string(),
1620 locator: "loc".to_string(),
1621 digest: Some("a".repeat(REF_DIGEST_MAX_CHARS + 1)),
1622 };
1623 let err = reference.validate().expect_err("digest over the bound must be rejected");
1624 assert!(matches!(err, MailError::TooLarge { field, .. } if field == "ref digest"));
1625 }
1626
1627 #[test]
1628 fn a_send_request_carrying_only_its_required_fields_decodes() {
1629 let json = r#"{"to":{"kind":"direct","participant":"bob"},"subject":"s","body":"b"}"#;
1634 let req: SendRequest = serde_json::from_str(json).expect("minimal send request decodes");
1635 assert!(req.reply_to.is_none());
1636 assert!(req.correlation.is_none());
1637 assert!(req.idempotency_key.is_none());
1638 assert!(req.refs.is_empty());
1639 req.validate().expect("and it validates");
1640 }
1641
1642 #[test]
1643 fn an_inbox_request_carrying_nothing_decodes_with_the_default_limit() {
1644 let req: InboxRequest = serde_json::from_str("{}").expect("empty inbox request decodes");
1645 assert!(req.since_unix_ms.is_none());
1646 assert_eq!(req.limit, INBOX_LIMIT_DEFAULT);
1647 }
1648
1649 #[test]
1650 fn send_request_old_shape_without_idempotency_key_still_deserializes_and_validates() {
1651 let json = serde_json::json!({
1652 "to": {"kind": "direct", "participant": "bob"},
1653 "subject": "hi",
1654 "body": "hi",
1655 "reply_to": null,
1656 "correlation": null
1657 });
1658 let request: SendRequest = serde_json::from_value(json)
1659 .expect("old shape without idempotency_key must still deserialize");
1660 assert_eq!(request.idempotency_key, None);
1661 request.validate().expect("decoded request is otherwise valid");
1662 }
1663
1664 #[test]
1665 fn directory_entry_serialises_with_id_label_and_sessions() {
1666 let entry = DirectoryEntry { id: participant("alice"), label: Some("Alice".to_string()), sessions: Vec::new() };
1667 let json = serde_json::to_value(&entry).expect("directory entry serializes");
1668 assert_eq!(json, serde_json::json!({"id": "alice", "label": "Alice", "sessions": []}));
1669 let decoded: DirectoryEntry = serde_json::from_value(json).expect("directory entry deserializes");
1670 assert_eq!(decoded, entry);
1671 }
1672
1673 #[test]
1674 fn directory_entry_rejects_a_control_character_label() {
1675 let entry =
1676 DirectoryEntry { id: participant("alice"), label: Some("bad\u{0007}label".to_string()), sessions: Vec::new() };
1677 let err = entry.validate().expect_err("control character in label must be rejected");
1678 assert!(matches!(err, MailError::Malformed { field, .. } if field == "label"));
1679 }
1680
1681 #[test]
1682 fn room_entry_round_trips_its_member_flag() {
1683 let entry = RoomEntry { id: RoomId::new("room-1").expect("valid room id"), member: true };
1684 let json = serde_json::to_value(&entry).expect("room entry serializes");
1685 assert_eq!(json, serde_json::json!({"id": "room-1", "member": true}));
1686 let decoded: RoomEntry = serde_json::from_value(json).expect("room entry deserializes");
1687 assert_eq!(decoded, entry);
1688 }
1689
1690 #[test]
1691 fn directory_validates_every_entry_it_carries() {
1692 let directory = Directory {
1693 participants: vec![DirectoryEntry { id: participant("alice"), label: None, sessions: Vec::new() }],
1694 rooms: vec![RoomEntry { id: RoomId::new("room-1").expect("valid room id"), member: false }],
1695 };
1696 directory.validate().expect("a directory of otherwise-valid entries validates");
1697 }
1698
1699 #[test]
1700 fn message_ref_rejects_non_hex_digest() {
1701 let reference = MessageRef {
1702 kind: "note".to_string(),
1703 locator: "loc".to_string(),
1704 digest: Some("not-hex".to_string()),
1705 };
1706 let err = reference.validate().expect_err("non-hex digest must be rejected");
1707 assert!(matches!(err, MailError::Malformed { field, .. } if field == "ref digest"));
1708 }
1709}