1use crate::room_state::message::MessageId;
14use serde::{Deserialize, Serialize};
15
16pub const CONTENT_TYPE_TEXT: u32 = 1;
18pub const CONTENT_TYPE_ACTION: u32 = 2;
19pub const CONTENT_TYPE_REPLY: u32 = 3;
20pub const CONTENT_TYPE_EVENT: u32 = 4;
21pub const TEXT_CONTENT_VERSION: u32 = 1;
25
26pub const ACTION_CONTENT_VERSION: u32 = 1;
28
29pub const REPLY_CONTENT_VERSION: u32 = 1;
31
32pub const EVENT_CONTENT_VERSION: u32 = 1;
34
35pub const EVENT_TYPE_JOIN: u32 = 1;
37pub const ACTION_TYPE_EDIT: u32 = 1;
41pub const ACTION_TYPE_DELETE: u32 = 2;
42pub const ACTION_TYPE_REACTION: u32 = 3;
43pub const ACTION_TYPE_REMOVE_REACTION: u32 = 4;
44#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
48pub struct TextContentV1 {
49 pub text: String,
50}
51
52impl TextContentV1 {
53 pub fn new(text: String) -> Self {
54 Self { text }
55 }
56
57 pub fn encode(&self) -> Vec<u8> {
59 encode_cbor(self)
60 }
61
62 pub fn decode(data: &[u8]) -> Result<Self, String> {
64 decode_cbor(data, "TextContentV1")
65 }
66}
67
68fn encode_cbor<T: Serialize>(value: &T) -> Vec<u8> {
70 let mut data = Vec::new();
71 ciborium::into_writer(value, &mut data).expect("CBOR serialization should not fail");
72 data
73}
74
75fn decode_cbor<T: serde::de::DeserializeOwned>(data: &[u8], type_name: &str) -> Result<T, String> {
77 ciborium::from_reader(data).map_err(|e| format!("Failed to decode {}: {}", type_name, e))
78}
79
80mod payload_bytes {
112 use serde::de::{Error as _, SeqAccess, Visitor};
113 use serde::{Deserializer, Serializer};
114 use std::fmt;
115
116 pub fn serialize<S: Serializer>(payload: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
117 serializer.serialize_bytes(payload)
118 }
119
120 struct BytesOrLegacySeq;
121
122 impl<'de> Visitor<'de> for BytesOrLegacySeq {
123 type Value = Vec<u8>;
124
125 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
126 f.write_str("a CBOR byte string, or a legacy array of byte values")
127 }
128
129 fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
130 Ok(v.to_vec())
131 }
132
133 fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
134 Ok(v)
135 }
136
137 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
139 const MAX_PREALLOC: usize = 4096;
152 let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(MAX_PREALLOC));
153 while let Some(byte) = seq.next_element::<u16>()? {
158 if byte > u8::MAX as u16 {
159 return Err(A::Error::custom(
162 "action payload element is not a byte value",
163 ));
164 }
165 out.push(byte as u8);
166 }
167 Ok(out)
168 }
169 }
170
171 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
172 deserializer.deserialize_any(BytesOrLegacySeq)
173 }
174}
175
176#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
178pub struct ActionContentV1 {
179 pub action_type: u32,
181 pub target: MessageId,
183 #[serde(with = "payload_bytes")]
190 pub payload: Vec<u8>,
191}
192
193impl ActionContentV1 {
194 pub fn edit(target: MessageId, new_text: String) -> Self {
196 Self {
197 action_type: ACTION_TYPE_EDIT,
198 target,
199 payload: encode_cbor(&EditPayload { new_text }),
200 }
201 }
202
203 pub fn delete(target: MessageId) -> Self {
205 Self {
206 action_type: ACTION_TYPE_DELETE,
207 target,
208 payload: Vec::new(),
209 }
210 }
211
212 pub fn reaction(target: MessageId, emoji: String) -> Self {
214 Self {
215 action_type: ACTION_TYPE_REACTION,
216 target,
217 payload: encode_cbor(&ReactionPayload { emoji }),
218 }
219 }
220
221 pub fn remove_reaction(target: MessageId, emoji: String) -> Self {
223 Self {
224 action_type: ACTION_TYPE_REMOVE_REACTION,
225 target,
226 payload: encode_cbor(&ReactionPayload { emoji }),
227 }
228 }
229
230 pub fn encode(&self) -> Vec<u8> {
232 encode_cbor(self)
233 }
234
235 pub fn decode(data: &[u8]) -> Result<Self, String> {
237 decode_cbor(data, "ActionContentV1")
238 }
239
240 pub fn edit_payload(&self) -> Option<EditPayload> {
242 if self.action_type == ACTION_TYPE_EDIT {
243 ciborium::from_reader(&self.payload[..]).ok()
244 } else {
245 None
246 }
247 }
248
249 pub fn reaction_payload(&self) -> Option<ReactionPayload> {
251 if self.action_type == ACTION_TYPE_REACTION
252 || self.action_type == ACTION_TYPE_REMOVE_REACTION
253 {
254 ciborium::from_reader(&self.payload[..]).ok()
255 } else {
256 None
257 }
258 }
259}
260
261#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
263pub struct EditPayload {
264 pub new_text: String,
265}
266
267#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
269pub struct ReactionPayload {
270 pub emoji: String,
271}
272
273#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
293pub struct ReplyContentV1 {
294 pub text: String,
295 pub target_message_id: MessageId,
296 pub target_author_name: String,
298 pub target_content_preview: String,
301}
302
303impl ReplyContentV1 {
304 pub fn new(
305 text: String,
306 target_message_id: MessageId,
307 target_author_name: String,
308 target_content_preview: String,
309 ) -> Self {
310 Self {
311 text,
312 target_message_id,
313 target_author_name,
314 target_content_preview,
315 }
316 }
317
318 pub fn encode(&self) -> Vec<u8> {
319 encode_cbor(self)
320 }
321
322 pub fn decode(data: &[u8]) -> Result<Self, String> {
323 decode_cbor(data, "ReplyContentV1")
324 }
325}
326
327#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
332pub struct EventContentV1 {
333 pub event_type: u32,
334}
335
336impl EventContentV1 {
337 pub fn join() -> Self {
338 Self {
339 event_type: EVENT_TYPE_JOIN,
340 }
341 }
342
343 pub fn encode(&self) -> Vec<u8> {
344 encode_cbor(self)
345 }
346
347 pub fn decode(data: &[u8]) -> Result<Self, String> {
348 decode_cbor(data, "EventContentV1")
349 }
350}
351
352#[derive(Clone, PartialEq, Debug)]
354pub enum DecodedContent {
355 Text(TextContentV1),
357 Action(ActionContentV1),
359 Reply(ReplyContentV1),
361 Event(EventContentV1),
363 Unknown {
365 content_type: u32,
366 content_version: u32,
367 },
368}
369
370impl DecodedContent {
371 pub fn is_action(&self) -> bool {
373 matches!(self, Self::Action(_))
374 }
375
376 pub fn is_event(&self) -> bool {
378 matches!(self, Self::Event(_))
379 }
380
381 pub fn target_id(&self) -> Option<&MessageId> {
383 match self {
384 Self::Action(action) => Some(&action.target),
385 _ => None,
386 }
387 }
388
389 pub fn as_text(&self) -> Option<&str> {
391 match self {
392 Self::Text(text) => Some(&text.text),
393 Self::Reply(reply) => Some(&reply.text),
394 _ => None,
395 }
396 }
397
398 pub fn to_display_string(&self) -> String {
400 match self {
401 Self::Text(text) => text.text.clone(),
402 Self::Reply(reply) => reply.text.clone(),
403 Self::Action(action) => match action.action_type {
404 ACTION_TYPE_EDIT => format!("[Edit of message {}]", action.target),
405 ACTION_TYPE_DELETE => format!("[Delete of message {}]", action.target),
406 ACTION_TYPE_REACTION => {
407 let emoji = action
408 .reaction_payload()
409 .map(|p| p.emoji)
410 .unwrap_or_else(|| "?".to_string());
411 format!("[Reaction {} to {}]", emoji, action.target)
412 }
413 ACTION_TYPE_REMOVE_REACTION => {
414 let emoji = action
415 .reaction_payload()
416 .map(|p| p.emoji)
417 .unwrap_or_else(|| "?".to_string());
418 format!("[Remove reaction {} from {}]", emoji, action.target)
419 }
420 _ => format!(
421 "[Unknown action type {} on {}]",
422 action.action_type, action.target
423 ),
424 },
425 Self::Event(event) => match event.event_type {
426 EVENT_TYPE_JOIN => "joined the room".to_string(),
427 _ => format!("[Unknown event type {}]", event.event_type),
428 },
429 Self::Unknown {
430 content_type,
431 content_version,
432 } => format!(
433 "[Unsupported message type {}.{} - please upgrade]",
434 content_type, content_version
435 ),
436 }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use freenet_scaffold::util::fast_hash;
444
445 fn test_message_id() -> MessageId {
446 MessageId(fast_hash(&[1, 2, 3, 4]))
447 }
448
449 #[test]
450 fn test_text_content_roundtrip() {
451 let content = TextContentV1::new("Hello, world!".to_string());
452 let encoded = content.encode();
453 let decoded = TextContentV1::decode(&encoded).unwrap();
454 assert_eq!(content, decoded);
455 }
456
457 #[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
461 struct LegacyActionContentV1 {
462 action_type: u32,
463 target: MessageId,
464 payload: Vec<u8>,
465 }
466
467 #[test]
472 fn legacy_array_payload_still_decodes() {
473 let action = ActionContentV1::edit(test_message_id(), "Edited text".to_string());
474 let legacy = LegacyActionContentV1 {
475 action_type: action.action_type,
476 target: action.target.clone(),
477 payload: action.payload.clone(),
478 };
479 let legacy_bytes = encode_cbor(&legacy);
480
481 let as_value: ciborium::value::Value =
486 ciborium::from_reader(&legacy_bytes[..]).expect("decode as generic CBOR");
487 let payload_field = as_value
488 .as_map()
489 .expect("a CBOR map")
490 .iter()
491 .find(|(k, _)| k.as_text() == Some("payload"))
492 .map(|(_, v)| v)
493 .expect("a payload field");
494 assert!(
495 payload_field.is_array(),
496 "the legacy fixture must be a CBOR array of integers, got {payload_field:?}"
497 );
498
499 let decoded = ActionContentV1::decode(&legacy_bytes)
500 .expect("legacy array-encoded payload must still decode");
501 assert_eq!(decoded, action, "legacy decode must be lossless");
502 assert_eq!(
503 decoded.edit_payload().expect("edit payload").new_text,
504 "Edited text",
505 "the edited text must survive a legacy-format decode"
506 );
507 }
508
509 fn legacy_bytes_with_raw_payload(raw_payload: &[u8]) -> Vec<u8> {
513 let mut bytes = encode_cbor(&LegacyActionContentV1 {
514 action_type: ACTION_TYPE_EDIT,
515 target: test_message_id(),
516 payload: Vec::new(),
517 });
518 assert_eq!(
519 bytes.pop(),
520 Some(0x80),
521 "expected a trailing empty CBOR array for the empty payload"
522 );
523 bytes.extend_from_slice(raw_payload);
524 bytes
525 }
526
527 #[test]
549 fn legacy_payload_with_lying_length_header_errors_not_panics() {
550 for (label, header) in [
551 (
552 "u64::MAX elements",
553 &[0x9B, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF][..],
554 ),
555 ("u32::MAX elements", &[0x9A, 0xFF, 0xFF, 0xFF, 0xFF][..]),
556 ("1,000,000 elements", &[0x9A, 0x00, 0x0F, 0x42, 0x40][..]),
557 ] {
558 let bytes = legacy_bytes_with_raw_payload(header);
559 assert!(
560 ActionContentV1::decode(&bytes).is_err(),
561 "{label}: a lying array header must be a decode error, never a panic/abort"
562 );
563 }
564 }
565
566 #[test]
569 fn legacy_payload_with_out_of_range_element_is_rejected() {
570 let bad = legacy_bytes_with_raw_payload(&[0x81, 0x19, 0x01, 0x2C]);
572 assert!(
573 ActionContentV1::decode(&bad).is_err(),
574 "an element above 255 must be rejected, not truncated"
575 );
576
577 let mixed = legacy_bytes_with_raw_payload(&[0x82, 0x01, 0x19, 0x01, 0x2C]);
579 assert!(
580 ActionContentV1::decode(&mixed).is_err(),
581 "a trailing out-of-range element must reject the whole payload"
582 );
583 }
584
585 #[test]
593 fn legacy_decode_covers_every_action_kind() {
594 let cases = vec![
595 ActionContentV1::edit(test_message_id(), "plain ascii".to_string()),
596 ActionContentV1::edit(test_message_id(), "café 🎉 naïve".to_string()),
597 ActionContentV1::delete(test_message_id()),
598 ActionContentV1::reaction(test_message_id(), "👍".to_string()),
599 ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string()),
600 ];
601
602 for action in cases {
603 let legacy_bytes = encode_cbor(&LegacyActionContentV1 {
604 action_type: action.action_type,
605 target: action.target.clone(),
606 payload: action.payload.clone(),
607 });
608 let decoded = ActionContentV1::decode(&legacy_bytes)
609 .unwrap_or_else(|e| panic!("legacy decode failed for {action:?}: {e}"));
610 assert_eq!(decoded, action, "legacy decode must be lossless");
611
612 if action.action_type == ACTION_TYPE_EDIT {
614 assert_eq!(
615 decoded.edit_payload().expect("edit payload").new_text,
616 action.edit_payload().expect("edit payload").new_text
617 );
618 } else if action.action_type == ACTION_TYPE_REACTION
619 || action.action_type == ACTION_TYPE_REMOVE_REACTION
620 {
621 assert_eq!(
622 decoded.reaction_payload().expect("reaction payload").emoji,
623 action.reaction_payload().expect("reaction payload").emoji
624 );
625 }
626 }
627 }
628
629 const LEGACY_EDIT_ACTION_PRE_443: &str = "a36b616374696f6e5f747970650166746172676574197c42677061796c6f6164981818a11868186e18651877185f1874186518781874186d18631861186618c318a9182018f0189f188e18891820186f186b";
646
647 fn hex_to_bytes(hex: &str) -> Vec<u8> {
648 (0..hex.len())
649 .step_by(2)
650 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex"))
651 .collect()
652 }
653
654 #[test]
655 fn frozen_pre_443_bytes_still_decode() {
656 let bytes = hex_to_bytes(LEGACY_EDIT_ACTION_PRE_443);
657 let decoded = ActionContentV1::decode(&bytes)
658 .expect("bytes written by already-deployed clients must still decode");
659
660 assert_eq!(decoded.action_type, ACTION_TYPE_EDIT);
661 assert_eq!(decoded.target, test_message_id());
662 assert_eq!(
663 decoded.edit_payload().expect("edit payload").new_text,
664 "café 🎉 ok",
665 "the edited text of a pre-#443 stored edit must survive verbatim"
666 );
667 }
668
669 #[test]
673 fn new_byte_string_payload_decodes_with_legacy_reader() {
674 let action = ActionContentV1::edit(test_message_id(), "Edited text".to_string());
675 let new_bytes = action.encode();
676
677 let legacy: LegacyActionContentV1 = ciborium::from_reader(&new_bytes[..])
678 .expect("a pre-#443 reader must still decode the new encoding");
679 assert_eq!(legacy.payload, action.payload);
680 assert_eq!(legacy.action_type, action.action_type);
681 assert_eq!(legacy.target, action.target);
682 }
683
684 #[test]
689 fn edit_action_does_not_cost_two_bytes_per_character() {
690 let text = "a".repeat(900);
691 let encoded_len = ActionContentV1::edit(test_message_id(), text.clone())
692 .encode()
693 .len();
694
695 let legacy_len = {
697 let action = ActionContentV1::edit(test_message_id(), text.clone());
698 encode_cbor(&LegacyActionContentV1 {
699 action_type: action.action_type,
700 target: action.target.clone(),
701 payload: action.payload,
702 })
703 .len()
704 };
705
706 assert!(
707 legacy_len > 1800,
708 "sanity: the legacy encoding should be ~2x the text ({legacy_len} bytes)"
709 );
710 assert!(
711 encoded_len < 1000,
712 "a 900-char edit must fit the default 1000-byte limit, got {encoded_len} bytes"
713 );
714 assert!(
715 encoded_len < text.len() + 100,
716 "edit overhead must be roughly constant, not proportional: \
717 {encoded_len} bytes for {} chars",
718 text.len()
719 );
720 }
721
722 #[test]
723 fn test_edit_action_roundtrip() {
724 let action = ActionContentV1::edit(test_message_id(), "New text".to_string());
725 let encoded = action.encode();
726 let decoded = ActionContentV1::decode(&encoded).unwrap();
727 assert_eq!(action, decoded);
728
729 let payload = decoded.edit_payload().unwrap();
730 assert_eq!(payload.new_text, "New text");
731 }
732
733 #[test]
734 fn test_delete_action_roundtrip() {
735 let action = ActionContentV1::delete(test_message_id());
736 let encoded = action.encode();
737 let decoded = ActionContentV1::decode(&encoded).unwrap();
738 assert_eq!(action, decoded);
739 assert_eq!(decoded.action_type, ACTION_TYPE_DELETE);
740 }
741
742 #[test]
743 fn test_reaction_action_roundtrip() {
744 let action = ActionContentV1::reaction(test_message_id(), "👍".to_string());
745 let encoded = action.encode();
746 let decoded = ActionContentV1::decode(&encoded).unwrap();
747 assert_eq!(action, decoded);
748
749 let payload = decoded.reaction_payload().unwrap();
750 assert_eq!(payload.emoji, "👍");
751 }
752
753 #[test]
754 fn test_remove_reaction_action_roundtrip() {
755 let action = ActionContentV1::remove_reaction(test_message_id(), "❤️".to_string());
756 let encoded = action.encode();
757 let decoded = ActionContentV1::decode(&encoded).unwrap();
758 assert_eq!(action, decoded);
759
760 let payload = decoded.reaction_payload().unwrap();
761 assert_eq!(payload.emoji, "❤️");
762 }
763
764 #[test]
765 fn test_reply_content_roundtrip() {
766 let reply = ReplyContentV1::new(
767 "I agree!".to_string(),
768 test_message_id(),
769 "Alice".to_string(),
770 "The original message text here...".to_string(),
771 );
772 let encoded = reply.encode();
773 let decoded = ReplyContentV1::decode(&encoded).unwrap();
774 assert_eq!(reply, decoded);
775
776 let dc = DecodedContent::Reply(reply.clone());
778 assert_eq!(dc.as_text(), Some("I agree!"));
779 assert_eq!(dc.to_display_string(), "I agree!");
780 assert!(!dc.is_action());
781 }
782
783 #[test]
784 fn test_decoded_content_display() {
785 let text = DecodedContent::Text(TextContentV1::new("Hello".to_string()));
786 assert_eq!(text.to_display_string(), "Hello");
787
788 let unknown = DecodedContent::Unknown {
789 content_type: 99,
790 content_version: 1,
791 };
792 assert!(unknown.to_display_string().contains("Unsupported"));
793 }
794
795 #[test]
796 fn test_event_content_roundtrip() {
797 let event = EventContentV1::join();
798 let encoded = event.encode();
799 let decoded = EventContentV1::decode(&encoded).unwrap();
800 assert_eq!(event, decoded);
801 assert_eq!(decoded.event_type, EVENT_TYPE_JOIN);
802
803 let dc = DecodedContent::Event(event);
804 assert!(dc.is_event());
805 assert!(!dc.is_action());
806 assert_eq!(dc.to_display_string(), "joined the room");
807 }
808
809 #[test]
810 fn test_join_event_message_body() {
811 let body = crate::room_state::message::RoomMessageBody::join_event();
812 assert!(body.is_event());
813 assert!(!body.is_action());
814 let decoded = body.decode_content().unwrap();
815 assert!(matches!(decoded, DecodedContent::Event(_)));
816 }
817}