Skip to main content

max / makenotwork

3.7 KB · 119 lines History Blame Raw
1 //! Message body validation and the stored message shape.
2
3 use serde::{Deserialize, Serialize};
4
5 use crate::error::ChatError;
6 use crate::ids::{MessageId, Nonce, RoomId, UserId};
7
8 /// Longest message a room accepts.
9 ///
10 /// Enforced here, on the server, as the authority. The client enforces the same
11 /// number for feedback while typing, but that is a courtesy and not a control.
12 pub const MAX_MESSAGE_LEN: usize = 500;
13
14 /// A validated message body.
15 ///
16 /// Constructing one is the only way to reach the send path, so "did anyone check
17 /// the length" is not a question a caller can get wrong.
18 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19 #[serde(transparent)]
20 pub struct MessageBody(String);
21
22 impl MessageBody {
23 /// Trims surrounding whitespace, then rejects empty or overlong bodies.
24 ///
25 /// Length is counted in `chars`, not bytes, so the limit means the same thing
26 /// to someone typing in a script that is not Latin.
27 ///
28 /// # Errors
29 ///
30 /// [`ChatError::MessageEmpty`] or [`ChatError::MessageTooLong`].
31 pub fn parse(raw: &str) -> Result<Self, ChatError> {
32 let trimmed = raw.trim();
33 if trimmed.is_empty() {
34 return Err(ChatError::MessageEmpty);
35 }
36 let len = trimmed.chars().count();
37 if len > MAX_MESSAGE_LEN {
38 return Err(ChatError::MessageTooLong {
39 len,
40 max: MAX_MESSAGE_LEN,
41 });
42 }
43 Ok(Self(trimmed.to_owned()))
44 }
45
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49 }
50
51 /// A message as stored and as broadcast.
52 ///
53 /// `body_html` is rendered once at insert by the host, through docengine's chat
54 /// preset, and never re-rendered: there is no edit path anywhere in the system.
55 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56 pub struct Message {
57 pub id: MessageId,
58 pub room_id: RoomId,
59 pub author_id: UserId,
60 pub body_html: String,
61 /// Unix seconds. Wall clock is adequate: a single process assigns every id,
62 /// and `MessageId` is what actually orders the room.
63 pub created_at: i64,
64 /// Echoed to the sender so an optimistically rendered message reconciles.
65 /// Absent for every other recipient and for backlog replay.
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub nonce: Option<Nonce>,
68 }
69
70 #[cfg(test)]
71 mod tests {
72 use super::*;
73
74 #[test]
75 fn trims_then_rejects_empty() {
76 assert!(matches!(
77 MessageBody::parse(" \n\t "),
78 Err(ChatError::MessageEmpty)
79 ));
80 assert!(matches!(
81 MessageBody::parse(""),
82 Err(ChatError::MessageEmpty)
83 ));
84 }
85
86 #[test]
87 fn trims_surrounding_whitespace() {
88 assert_eq!(MessageBody::parse(" hi ").unwrap().as_str(), "hi");
89 }
90
91 #[test]
92 fn boundary_is_inclusive() {
93 let at = "x".repeat(MAX_MESSAGE_LEN);
94 assert!(MessageBody::parse(&at).is_ok());
95
96 let over = "x".repeat(MAX_MESSAGE_LEN + 1);
97 assert!(matches!(
98 MessageBody::parse(&over),
99 Err(ChatError::MessageTooLong { len, max })
100 if len == MAX_MESSAGE_LEN + 1 && max == MAX_MESSAGE_LEN
101 ));
102 }
103
104 #[test]
105 fn length_counts_chars_not_bytes() {
106 // Four bytes each: a body at the char limit is well over it in bytes,
107 // and must still be accepted.
108 let emoji_free_multibyte = "\u{1F00}".repeat(MAX_MESSAGE_LEN);
109 assert!(emoji_free_multibyte.len() > MAX_MESSAGE_LEN);
110 assert!(MessageBody::parse(&emoji_free_multibyte).is_ok());
111 }
112
113 #[test]
114 fn length_is_measured_after_trimming() {
115 let padded = format!(" {} ", "x".repeat(MAX_MESSAGE_LEN));
116 assert!(MessageBody::parse(&padded).is_ok());
117 }
118 }
119