use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Serialize};
pub const DM_BODY_MAGIC: u8 = 0x80;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DirectMessageBody {
Text {
#[serde(default)]
text: String,
},
Invite(Box<InvitePayload>),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvitePayload {
pub room_owner_vk: VerifyingKey,
pub invitation_payload: Vec<u8>,
#[serde(default)]
pub personal_message: Option<String>,
}
pub fn encode_body(body: &DirectMessageBody) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(64);
out.push(DM_BODY_MAGIC);
ciborium::ser::into_writer(body, &mut out)
.map_err(|e| format!("encode_body: CBOR serialization failed: {}", e))?;
Ok(out)
}
pub fn decode_body(bytes: &[u8]) -> Result<DirectMessageBody, String> {
if bytes.is_empty() {
return Ok(DirectMessageBody::Text {
text: String::new(),
});
}
if bytes[0] == DM_BODY_MAGIC {
let body: DirectMessageBody = ciborium::de::from_reader(&bytes[1..]).map_err(|e| {
format!(
"decode_body: CBOR deserialization of new-format body failed: {}",
e
)
})?;
return Ok(body);
}
let text = String::from_utf8_lossy(bytes).into_owned();
Ok(DirectMessageBody::Text { text })
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn sample_vk() -> VerifyingKey {
let seed = [7u8; 32];
SigningKey::from_bytes(&seed).verifying_key()
}
#[test]
fn encode_decode_text_round_trip() {
let body = DirectMessageBody::Text {
text: "Hello, peer".to_string(),
};
let bytes = encode_body(&body).expect("encode");
let decoded = decode_body(&bytes).expect("decode");
assert_eq!(body, decoded);
}
#[test]
fn encode_decode_text_empty_round_trip() {
let body = DirectMessageBody::Text {
text: String::new(),
};
let bytes = encode_body(&body).expect("encode");
let decoded = decode_body(&bytes).expect("decode");
assert_eq!(body, decoded);
}
#[test]
fn encode_decode_invite_round_trip() {
let body = DirectMessageBody::Invite(Box::new(InvitePayload {
room_owner_vk: sample_vk(),
invitation_payload: vec![1, 2, 3, 4, 5],
personal_message: Some("join us!".to_string()),
}));
let bytes = encode_body(&body).expect("encode");
let decoded = decode_body(&bytes).expect("decode");
assert_eq!(body, decoded);
}
#[test]
fn encode_decode_invite_round_trip_no_personal_message() {
let body = DirectMessageBody::Invite(Box::new(InvitePayload {
room_owner_vk: sample_vk(),
invitation_payload: vec![],
personal_message: None,
}));
let bytes = encode_body(&body).expect("encode");
let decoded = decode_body(&bytes).expect("decode");
assert_eq!(body, decoded);
}
#[test]
fn new_format_bytes_start_with_magic() {
let body = DirectMessageBody::Text {
text: "anything".to_string(),
};
let bytes = encode_body(&body).expect("encode");
assert_eq!(bytes[0], DM_BODY_MAGIC);
}
#[test]
fn legacy_text_decodes_as_text() {
let legacy_bytes = b"hello from the past";
let decoded = decode_body(legacy_bytes).expect("decode legacy");
assert_eq!(
decoded,
DirectMessageBody::Text {
text: "hello from the past".to_string()
}
);
}
#[test]
fn legacy_multibyte_utf8_decodes_as_text() {
let legacy_bytes = "café".as_bytes();
let decoded = decode_body(legacy_bytes).expect("decode legacy");
assert_eq!(
decoded,
DirectMessageBody::Text {
text: "café".to_string()
}
);
}
#[test]
fn empty_bytes_decode_as_empty_text() {
let decoded = decode_body(&[]).expect("decode empty");
assert_eq!(
decoded,
DirectMessageBody::Text {
text: String::new()
}
);
}
#[test]
fn malformed_new_format_returns_err() {
let bytes = vec![DM_BODY_MAGIC, 0xff, 0xff, 0xff];
assert!(decode_body(&bytes).is_err());
}
#[test]
fn encoding_is_deterministic_for_text() {
let body = DirectMessageBody::Text {
text: "stable bytes please".to_string(),
};
let a = encode_body(&body).expect("first encode");
let b = encode_body(&body).expect("second encode");
assert_eq!(a, b, "encode_body must be deterministic");
}
#[test]
fn encoding_is_deterministic_for_invite() {
let body = DirectMessageBody::Invite(Box::new(InvitePayload {
room_owner_vk: sample_vk(),
invitation_payload: vec![0xde, 0xad, 0xbe, 0xef],
personal_message: Some("stable".to_string()),
}));
let a = encode_body(&body).expect("first encode");
let b = encode_body(&body).expect("second encode");
assert_eq!(a, b, "encode_body must be deterministic");
}
#[test]
fn invite_with_large_payload_round_trips() {
let payload = vec![0xAB; 16 * 1024];
let body = DirectMessageBody::Invite(Box::new(InvitePayload {
room_owner_vk: sample_vk(),
invitation_payload: payload,
personal_message: None,
}));
let bytes = encode_body(&body).expect("encode");
let decoded = decode_body(&bytes).expect("decode");
assert_eq!(body, decoded);
}
#[test]
fn legacy_text_with_invalid_utf8_decodes_lossily() {
let bytes: Vec<u8> = vec![b'h', b'i', 0xFF, b'!'];
let decoded = decode_body(&bytes).expect("decode");
match decoded {
DirectMessageBody::Text { text } => {
assert!(text.starts_with("hi"), "got: {:?}", text);
assert!(text.ends_with("!"), "got: {:?}", text);
assert!(text.contains('\u{FFFD}'), "expected replacement char");
}
other => panic!("expected Text, got {:?}", other),
}
}
#[test]
fn legacy_plain_text_never_collides_with_magic() {
assert!(
DM_BODY_MAGIC >= 0x80 && DM_BODY_MAGIC <= 0xBF,
"DM_BODY_MAGIC must be a UTF-8 continuation byte (0x80..=0xBF) so it cannot start a valid UTF-8 string"
);
}
}