use std::cmp::Reverse; use std::collections::BTreeMap; use std::fmt; use anyhow::Result; use nostr_sdk::prelude::*; use crate::derive::channel_group_key; use crate::edition::{ AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, }; use crate::stream::{ KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal, }; use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; pub const KIND_MESSAGE: u16 = 9; pub const KIND_COMMENT: u16 = 1111; pub const KIND_REACTION: u16 = 7; pub const KIND_DELETE: u16 = 5; pub const KIND_EDIT: u16 = 3302; pub const KIND_FILE: u16 = 15; pub const KIND_TIMER_NOTICE: u16 = 1740; pub const KIND_WEBXDC: u16 = 3310; pub const KIND_TYPING: u16 = 23311; const TAG_QUOTE: &str = "q"; const TAG_TARGET: &str = "e"; const TAG_TARGET_KIND: &str = "k"; const TAG_ROOT: &str = "E"; const TAG_ROOT_KIND: &str = "K"; const TAG_ROOT_AUTHOR: &str = "P"; const TAG_TARGET_AUTHOR: &str = "p"; const TAG_EXPIRATION: &str = "expiration"; const TAG_TIMER: &str = "timer"; #[derive(Debug)] pub enum ChatError { Stream(StreamError), NotEncryptedSealed, UnknownKind(u16), MissingTag(&'static str), DuplicateTag(&'static str), BadTag(&'static str), /// Neither a delete nor a timer notice may be erased by the policy it carries. ExemptExpiration, } impl fmt::Display for ChatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ChatError::Stream(error) => write!(f, "stream: {error}"), ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"), ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"), ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"), ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"), ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"), ChatError::ExemptExpiration => { write!(f, "a delete or timer notice must not carry an expiration") } } } } impl std::error::Error for ChatError {} impl From for ChatError { fn from(error: StreamError) -> Self { ChatError::Stream(error) } } /// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReplyRef { pub id: EventId, pub author: Option, } /// A reference that also names the referenced event's kind, which `K`/`k` must commit on the wire. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Target { pub reply: ReplyRef, pub kind: u16, } #[derive(Debug, Clone)] pub enum ChatAction { Message { reply_to: Option, thread_root: Option, }, Reaction { target: EventId, emoji: String, }, Edit { target: EventId, content: String, }, Delete { target: EventId, target_kind: Option, citation: Option, }, Typing, Opaque, TimerNotice { seconds: u64, }, } #[derive(Debug, Clone)] pub struct ChatRumor { pub id: EventId, pub author: PublicKey, pub kind: Kind, pub channel: ChannelId, pub epoch: Epoch, pub at_ms: u64, pub content: String, pub expiration: Option, pub action: ChatAction, } /// A channel's timeline row, with every edit, delete and reaction folded in. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChatMessage { pub id: EventId, pub author: PublicKey, pub channel: ChannelId, pub epoch: Epoch, pub kind: Kind, pub content: String, pub reply_to: Option, pub thread_root: Option, pub at_ms: u64, pub expiration: Option, pub edited_at: Option, pub deleted: bool, pub reactions: BTreeMap, } pub fn build_message( author: PublicKey, channel: &ChannelId, epoch: Epoch, content: &str, quote: Option<&ReplyRef>, at_ms: u64, timer: Option, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); if let Some(quote) = quote { tags.push(reply_tag(TAG_QUOTE, quote)); } tags.extend(expiration_tag(at_ms, timer)); build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms) } /// `parent` is the immediate parent; a `None` root means the parent is the thread's root. #[allow(clippy::too_many_arguments)] pub fn build_comment( author: PublicKey, channel: &ChannelId, epoch: Epoch, content: &str, parent: &Target, root: Option<&Target>, at_ms: u64, timer: Option, ) -> UnsignedEvent { let root = root.unwrap_or(parent); let mut tags = channel_binding_tags(channel, epoch); tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()])); tags.push(reply_tag(TAG_ROOT, &root.reply)); if let Some(root_author) = root.reply.author { tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()])); } tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()])); tags.push(reply_tag(TAG_TARGET, &parent.reply)); if let Some(parent_author) = parent.reply.author { tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()])); } tags.extend(expiration_tag(at_ms, timer)); build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms) } pub fn build_reaction( author: PublicKey, channel: &ChannelId, epoch: Epoch, target: &Target, emoji: &str, at_ms: u64, timer: Option, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()])); if let Some(target_author) = target.reply.author { tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()])); } tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()])); tags.extend(expiration_tag(at_ms, timer)); build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms) } pub fn build_edit( author: PublicKey, channel: &ChannelId, epoch: Epoch, target: EventId, content: &str, at_ms: u64, timer: Option, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); tags.push(Tag::custom(TAG_TARGET, [target.to_hex()])); tags.extend(expiration_tag(at_ms, timer)); build_rumor_ms(KIND_EDIT, author, content, tags, at_ms) } /// CORD-08 §4: informational, gated by the roster rather than by the fold. pub fn build_timer_notice( author: PublicKey, channel: &ChannelId, epoch: Epoch, seconds: u64, at_ms: u64, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); tags.push(Tag::custom(TAG_TIMER, [seconds.to_string()])); build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms) } /// Derived from the signed `created_at`, so a later metadata edit never reaches back. fn expiration_tag(at_ms: u64, timer: Option) -> Option { timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()])) } pub fn build_delete( author: PublicKey, channel: &ChannelId, epoch: Epoch, target: EventId, target_kind: Option, citation: Option<&AuthorityCitation>, at_ms: u64, ) -> UnsignedEvent { let mut tags = channel_binding_tags(channel, epoch); tags.push(Tag::custom(TAG_TARGET, [target.to_hex()])); if let Some(target_kind) = target_kind { tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()])); } if let Some(citation) = citation { tags.push(citation_tag(citation)); } build_rumor_ms(KIND_DELETE, author, "", tags, at_ms) } pub fn build_typing( author: PublicKey, channel: &ChannelId, epoch: Epoch, at_ms: u64, ) -> UnsignedEvent { build_rumor_ms( KIND_TYPING, author, "", channel_binding_tags(channel, epoch), at_ms, ) } /// `ephemeral` picks the 21059 wrap, which relays must not store. pub fn seal_rumor( rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool, ) -> Result<(Event, Keys), ChatError> { let kind = rumor.kind.as_u16(); if !is_chat_kind(kind) { return Err(ChatError::UnknownKind(kind)); } let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; let wrap_kind = if ephemeral { KIND_WRAP_EPHEMERAL } else { KIND_WRAP }; // The wrap's copy is for relays; the inner one drives the local purge. let expiration: Vec = rumor .tags .iter() .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION)) .cloned() .collect(); Ok(wrap_seal( &seal, group, wrap_kind, rumor.created_at, &expiration, )?) } /// The claimed channel and epoch must both be the ones that opened the wrap. pub fn open( wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch, ) -> Result<(OpenedStream, ChatRumor), ChatError> { let opened = open_wrap(wrap, group)?; if opened.seal_form != SealForm::Encrypted { return Err(ChatError::NotEncryptedSealed); } check_channel_binding(&opened.rumor, channel, epoch)?; let chat = typed(&opened.rumor, channel, epoch)?; Ok((opened, chat)) } /// `secret` is the `community_root` for a public channel, its own key for a private one. pub fn plane_keys( held: &[(Epoch, [u8; 32])], channel: &ChannelId, ) -> Result> { held.iter() .map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?))) .collect() } pub fn fold( rumors: &[ChatRumor], now: Timestamp, can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool, ) -> Vec { let mut order: Vec = (0..rumors.len()).collect(); order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id)); let mut messages: Vec = Vec::new(); let mut slot: BTreeMap = BTreeMap::new(); for index in order { let rumor = &rumors[index]; if expired(rumor, now) { continue; } let (reply_to, thread_root) = match &rumor.action { ChatAction::Message { reply_to, thread_root, } => ( reply_to.map(|reply| reply.id), thread_root.map(|reply| reply.id), ), ChatAction::TimerNotice { .. } => (None, None), _ => continue, }; slot.insert(rumor.id, messages.len()); messages.push(ChatMessage { id: rumor.id, author: rumor.author, channel: rumor.channel, epoch: rumor.epoch, kind: rumor.kind, content: rumor.content.clone(), reply_to, thread_root, at_ms: rumor.at_ms, expiration: rumor.expiration, edited_at: None, deleted: false, reactions: BTreeMap::new(), }); } let mut mutations: Vec = (0..rumors.len()).collect(); mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id))); for index in mutations { let rumor = &rumors[index]; match &rumor.action { ChatAction::Edit { target, content } => { let Some(&slot) = slot.get(target) else { continue; }; let message = &mut messages[slot]; if message.deleted || message.author != rumor.author { continue; } message.content = content.clone(); message.edited_at = Some(rumor.at_ms); } ChatAction::Delete { target, citation, .. } => { let Some(&slot) = slot.get(target) else { continue; }; let author = messages[slot].author; if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) { messages[slot].deleted = true; } } ChatAction::Reaction { target, emoji } => { let Some(&slot) = slot.get(target) else { continue; }; messages[slot].reactions.insert(rumor.author, emoji.clone()); } ChatAction::Message { .. } | ChatAction::Typing | ChatAction::TimerNotice { .. } | ChatAction::Opaque => {} } } messages.sort_by_key(|message| (Reverse(message.at_ms), message.id)); messages } /// CORD-08 §3: an expired rumor is never displayed, whatever its ingest path. pub fn expired(rumor: &ChatRumor, now: Timestamp) -> bool { rumor.expiration.is_some_and(|expiration| expiration <= now) } fn is_chat_kind(kind: u16) -> bool { matches!( kind, KIND_MESSAGE | KIND_COMMENT | KIND_REACTION | KIND_DELETE | KIND_EDIT | KIND_FILE | KIND_TIMER_NOTICE | KIND_WEBXDC | KIND_TYPING ) } fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result { let expiration = expiration_of(rumor)?; if expiration.is_some() && matches!(rumor.kind.as_u16(), KIND_DELETE | KIND_TIMER_NOTICE) { return Err(ChatError::ExemptExpiration); } Ok(ChatRumor { id: rumor.id.unwrap_or_else(|| rumor.compute_id()), author: rumor.pubkey, kind: rumor.kind, channel: *channel, epoch, at_ms: resolve_ms_strict(rumor)?, content: rumor.content.clone(), expiration, action: action_of(rumor)?, }) } fn action_of(rumor: &UnsignedEvent) -> Result { let kind = rumor.kind.as_u16(); match kind { KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message { reply_to: optional_reply(rumor, TAG_QUOTE)?, thread_root: None, }), KIND_COMMENT => Ok(ChatAction::Message { reply_to: optional_reply(rumor, TAG_TARGET)?, thread_root: optional_reply(rumor, TAG_ROOT)?, }), KIND_REACTION => Ok(ChatAction::Reaction { target: required_id(rumor, TAG_TARGET)?, emoji: rumor.content.clone(), }), KIND_EDIT => Ok(ChatAction::Edit { target: required_id(rumor, TAG_TARGET)?, content: rumor.content.clone(), }), KIND_DELETE => Ok(ChatAction::Delete { target: required_id(rumor, TAG_TARGET)?, target_kind: optional_kind(rumor, TAG_TARGET_KIND)?, citation: optional_citation(rumor)?, }), KIND_TYPING => Ok(ChatAction::Typing), KIND_TIMER_NOTICE => Ok(ChatAction::TimerNotice { seconds: timer_of(rumor)?, }), KIND_WEBXDC => Ok(ChatAction::Opaque), other => Err(ChatError::UnknownKind(other)), } } fn timer_of(rumor: &UnsignedEvent) -> Result { let fields = tag(rumor, TAG_TIMER)?.ok_or(ChatError::MissingTag(TAG_TIMER))?; canonical_decimal(value(fields, TAG_TIMER)?).ok_or(ChatError::BadTag(TAG_TIMER)) } fn optional_reply( rumor: &UnsignedEvent, name: &'static str, ) -> Result, ChatError> { let Some(fields) = tag(rumor, name)? else { return Ok(None); }; // NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3. let author = match fields.get(3).map(String::as_str) { Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?), _ => None, }; Ok(Some(ReplyRef { id: hex_id(fields, name)?, author, })) } fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result { let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?; hex_id(fields, name) } fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result, ChatError> { let Some(fields) = tag(rumor, name)? else { return Ok(None); }; let raw = value(fields, name)?; let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?; u16::try_from(kind) .map(Some) .map_err(|_| ChatError::BadTag(name)) } fn optional_citation(rumor: &UnsignedEvent) -> Result, ChatError> { let Some(fields) = tag(rumor, TAG_CITATION)? else { return Ok(None); }; citation_from(fields) .map(Some) .ok_or(ChatError::BadTag(TAG_CITATION)) } pub fn expiration_of(rumor: &UnsignedEvent) -> Result, ChatError> { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { return Ok(None); }; let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?) .ok_or(ChatError::BadTag(TAG_EXPIRATION))?; Ok(Some(Timestamp::from_secs(seconds))) } fn reply_tag(name: &str, reply: &ReplyRef) -> Tag { Tag::custom( name, [ reply.id.to_hex(), String::new(), reply .author .map(|author| author.to_hex()) .unwrap_or_default(), ], ) } fn tag<'a>( rumor: &'a UnsignedEvent, name: &'static str, ) -> Result, ChatError> { let mut found: Option<&[String]> = None; for candidate in rumor.tags.iter() { let fields = candidate.as_slice(); if fields.first().map(String::as_str) != Some(name) { continue; } if found.is_some() { return Err(ChatError::DuplicateTag(name)); } found = Some(fields); } Ok(found) } fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> { fields .get(1) .map(String::as_str) .ok_or(ChatError::BadTag(name)) } fn hex_id(fields: &[String], name: &'static str) -> Result { let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) } fn pubkey(hex: &str, name: &'static str) -> Result { let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?; PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) } #[cfg(test)] mod tests { use super::*; const SECRET: [u8; 32] = [0x2du8; 32]; const AT: u64 = 1_700_000_000_417; /// Well past every timestamp these tests use. fn now() -> Timestamp { Timestamp::from_secs(2_000_000_000) } fn channel() -> ChannelId { ChannelId::from_bytes([0x9cu8; 32]) } fn group() -> GroupKey { channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives") } fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { seal_rumor(rumor, group, author, false).expect("seals").0 } fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { open(&sealed(rumor, group, author), group, &channel(), epoch) .expect("opens") .1 } fn target(id: EventId, author: &Keys) -> Target { Target { reply: ReplyRef { id, author: Some(author.public_key()), }, kind: KIND_MESSAGE, } } #[test] fn a_second_holder_folds_edits_reactions_and_a_self_delete() { let alice = Keys::generate(); let carol = Keys::generate(); let group = group(); let message = build_message( alice.public_key(), &channel(), Epoch(0), "hello", None, AT, None, ); let id = message.compute_id(); let rumors = vec![ read(&message, &group, &alice, Epoch(0)), read( &build_reaction( carol.public_key(), &channel(), Epoch(0), &target(id, &alice), "🔥", AT + 1_000, None, ), &group, &carol, Epoch(0), ), read( &build_edit( alice.public_key(), &channel(), Epoch(0), id, "hello (fixed)", AT + 2_000, None, ), &group, &alice, Epoch(0), ), read( &build_delete( alice.public_key(), &channel(), Epoch(0), id, Some(KIND_MESSAGE), None, AT + 3_000, ), &group, &alice, Epoch(0), ), ]; let folded = fold(&rumors, now(), |_, _, _| false); assert_eq!(folded.len(), 1); assert_eq!(folded[0].id, id); assert_eq!(folded[0].content, "hello (fixed)"); assert_eq!(folded[0].edited_at, Some(AT + 2_000)); assert_eq!( folded[0].reactions.get(&carol.public_key()), Some(&"🔥".to_owned()) ); assert!(folded[0].deleted); } #[test] fn an_edit_or_delete_from_another_author_is_ignored() { let alice = Keys::generate(); let bob = Keys::generate(); let group = group(); let message = build_message( alice.public_key(), &channel(), Epoch(0), "hello", None, AT, None, ); let id = message.compute_id(); let rumors = vec![ read(&message, &group, &alice, Epoch(0)), read( &build_edit( bob.public_key(), &channel(), Epoch(0), id, "mine now", AT + 1_000, None, ), &group, &bob, Epoch(0), ), read( &build_delete( bob.public_key(), &channel(), Epoch(0), id, Some(KIND_MESSAGE), None, AT + 2_000, ), &group, &bob, Epoch(0), ), ]; let folded = fold(&rumors, now(), |_, _, _| false); assert_eq!(folded.len(), 1); assert_eq!(folded[0].content, "hello"); assert_eq!(folded[0].edited_at, None); assert!(!folded[0].deleted); } #[test] fn a_comment_carries_its_root_and_its_parent() { let alice = Keys::generate(); let bob = Keys::generate(); let group = group(); let root = build_message( alice.public_key(), &channel(), Epoch(0), "root", None, AT, None, ); let root_id = root.compute_id(); let parent = build_message( bob.public_key(), &channel(), Epoch(0), "parent", None, AT + 1_000, None, ); let parent_id = parent.compute_id(); let comment = build_comment( alice.public_key(), &channel(), Epoch(0), "deep", &target(parent_id, &bob), Some(&target(root_id, &alice)), AT + 2_000, None, ); assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"])); assert!( comment .tags .iter() .any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() }) ); assert!( comment .tags .iter() .any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() }) ); let rumor = read(&comment, &group, &alice, Epoch(0)); let ChatAction::Message { reply_to, thread_root, } = &rumor.action else { panic!("a comment is a message row") }; assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id)); assert_eq!(thread_root.map(|root| root.id), Some(root_id)); } #[test] fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() { let alice = Keys::generate(); let group = group(); let plain = build_message( alice.public_key(), &channel(), Epoch(0), "hello", None, AT, None, ); assert!( open( &sealed(&plain, &group, &alice), &group, &channel(), Epoch(0) ) .is_ok() ); // The keyholder re-addresses their own rumor: the binding is judged // against the plane whose key opened the wrap, never the rumor's claim. let elsewhere = ChannelId::from_bytes([0xeeu8; 32]); assert!(matches!( open( &sealed(&plain, &group, &alice), &group, &elsewhere, Epoch(0) ), Err(ChatError::Stream(StreamError::ChannelMismatch)) )); let stale = build_message( alice.public_key(), &channel(), Epoch(1), "stale", None, AT, None, ); assert!(matches!( open( &sealed(&stale, &group, &alice), &group, &channel(), Epoch(0) ), Err(ChatError::Stream(StreamError::EpochMismatch)) )); // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a // chat rumor however well-formed it looks. let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals"); let (wrap, _) = wrap_seal( &seal, &group, KIND_WRAP, Timestamp::from_secs(AT / 1000), &[], ) .expect("wraps"); assert!(matches!( open(&wrap, &group, &channel(), Epoch(0)), Err(ChatError::NotEncryptedSealed) )); let ghost = build_rumor_ms( 3300, alice.public_key(), "v1 ghost", channel_binding_tags(&channel(), Epoch(0)), AT, ); assert!(matches!( seal_rumor(&ghost, &group, &alice, false), Err(ChatError::UnknownKind(3300)) )); let mut tags = channel_binding_tags(&channel(), Epoch(0)); tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)])); tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)])); let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT); assert!(matches!( open( &sealed(&ambiguous, &group, &alice), &group, &channel(), Epoch(0) ), Err(ChatError::DuplicateTag(TAG_TARGET)) )); } #[test] fn a_moderator_delete_needs_the_roster_and_a_citation() { let alice = Keys::generate(); let moderator = Keys::generate(); let peer = Keys::generate(); let group = group(); let message = read( &build_message( alice.public_key(), &channel(), Epoch(0), "hello", None, AT, None, ), &group, &alice, Epoch(0), ); let id = message.id; let citation = AuthorityCitation { entity: [0x33; 32], version: 1, hash: [0x44; 32], }; let delete = |author: &Keys, citation: Option<&AuthorityCitation>| { read( &build_delete( author.public_key(), &channel(), Epoch(0), id, Some(KIND_MESSAGE), citation, AT + 1_000, ), &group, author, Epoch(0), ) }; let can_delete = |actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| { actor != author && citation.is_some() && actor == &moderator.public_key() }; let cited = vec![message.clone(), delete(&moderator, Some(&citation))]; assert!(matches!( &cited[1].action, ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation )); assert!( fold(&cited, now(), can_delete)[0].deleted, "a cited moderator delete lands" ); let uncited = vec![message.clone(), delete(&moderator, None)]; assert!( !fold(&uncited, now(), can_delete)[0].deleted, "an uncited delete names no rank" ); let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))]; assert!( !fold(&peer_delete, now(), can_delete)[0].deleted, "a peer's delete is not authority" ); let own = vec![message.clone(), delete(&alice, None)]; assert!( fold(&own, now(), |_, _, _| false)[0].deleted, "a self-delete never consults the predicate" ); } #[test] fn a_timer_rides_durable_rumors_and_expiry_gates_the_fold() { let alice = Keys::generate(); let group = group(); let expires = (AT / 1000 + 60).to_string(); // Computed from the signed `created_at`, and mirrored onto the wrap so // relays drop the ciphertext too. let message = build_message( alice.public_key(), &channel(), Epoch(0), "tick", None, AT, Some(60), ); assert!( message .tags .iter() .any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()]) ); assert!( sealed(&message, &group, &alice) .tags .iter() .any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()]) ); let live = read(&message, &group, &alice, Epoch(0)); assert_eq!(live.expiration, Some(Timestamp::from_secs(AT / 1000 + 60))); assert!(!expired(&live, Timestamp::from_secs(AT / 1000 + 59))); assert!(expired(&live, Timestamp::from_secs(AT / 1000 + 60))); assert_eq!( fold( std::slice::from_ref(&live), Timestamp::from_secs(AT / 1000 + 59), |_, _, _| false ) .len(), 1 ); assert_eq!( fold(&[live], Timestamp::from_secs(AT / 1000 + 60), |_, _, _| { false }) .len(), 0 ); // A delete is a tombstone and a notice documents the policy, so neither // may be erased by the policy it carries. let mut expiring = channel_binding_tags(&channel(), Epoch(0)); expiring.push(Tag::custom(TAG_EXPIRATION, ["1"])); expiring.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)])); for kind in [KIND_DELETE, KIND_TIMER_NOTICE] { let rumor = build_rumor_ms(kind, alice.public_key(), "", expiring.clone(), AT); assert!(matches!( open( &sealed(&rumor, &group, &alice), &group, &channel(), Epoch(0) ), Err(ChatError::ExemptExpiration) )); } // A notice is a row of its own; whether its author may be believed // about policy is the roster's call, not the fold's. let notice = build_timer_notice(alice.public_key(), &channel(), Epoch(0), 3_600, AT); let folded = fold( &[read(¬ice, &group, &alice, Epoch(0))], now(), |_, _, _| false, ); assert_eq!(folded.len(), 1); assert_eq!(folded[0].kind, Kind::Custom(KIND_TIMER_NOTICE)); let mut malformed = channel_binding_tags(&channel(), Epoch(0)); malformed.push(Tag::custom(TAG_TIMER, ["060"])); let rumor = build_rumor_ms(KIND_TIMER_NOTICE, alice.public_key(), "", malformed, AT); assert!(matches!( open( &sealed(&rumor, &group, &alice), &group, &channel(), Epoch(0) ), Err(ChatError::BadTag(TAG_TIMER)) )); } }