1use std::collections::{BTreeMap, BTreeSet};
22
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent};
24
25use super::super::edition::AuthorityCitation;
26use super::derive::GroupKey;
27use super::kind;
28use super::stream::{self, OpenedStream, SealForm, StreamError};
29
30pub const MAX_FUTURE_MS: u64 = 3_600_000;
34
35pub const SNAPSHOT_CHUNK: usize = 400;
39
40const TAG_INVITE: &str = "invite";
41const TAG_TARGET: &str = "p";
42const TAG_SNAP: &str = "snap";
43
44const VERB_JOIN: &str = "join";
45const VERB_LEAVE: &str = "leave";
46
47#[derive(Debug)]
49pub enum GuestbookError {
50 Stream(StreamError),
51 NotGuestbook(u16),
53 NotEncryptedSealed,
57 BadVerb,
60 MissingTag(&'static str),
61 DuplicateTag(&'static str),
63 BadTag(&'static str),
66 BadSnapshotContent,
68}
69
70impl std::fmt::Display for GuestbookError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 GuestbookError::Stream(e) => write!(f, "stream: {e}"),
74 GuestbookError::NotGuestbook(k) => write!(f, "rumor kind {k} is not a guestbook event"),
75 GuestbookError::NotEncryptedSealed => write!(f, "guestbook events must ride an encrypted seal"),
76 GuestbookError::BadVerb => write!(f, "3306 content is not exactly join/leave"),
77 GuestbookError::MissingTag(t) => write!(f, "missing guestbook tag: {t}"),
78 GuestbookError::DuplicateTag(t) => write!(f, "duplicate guestbook tag: {t}"),
79 GuestbookError::BadTag(t) => write!(f, "unparseable guestbook tag: {t}"),
80 GuestbookError::BadSnapshotContent => write!(f, "snapshot content is not a JSON array"),
81 }
82 }
83}
84
85impl std::error::Error for GuestbookError {}
86
87impl From<StreamError> for GuestbookError {
88 fn from(e: StreamError) -> Self {
89 GuestbookError::Stream(e)
90 }
91}
92
93pub fn build_join_rumor(author: PublicKey, invite_attribution: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent {
98 let mut tags = Vec::new();
99 if let Some((creator, label)) = invite_attribution {
100 tags.push(Tag::custom(
101 TAG_INVITE,
102 [creator.to_string(), label.to_string()],
103 ));
104 }
105 stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_JOIN, tags, at_ms)
106}
107
108pub fn build_leave_rumor(author: PublicKey, at_ms: u64) -> UnsignedEvent {
110 stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_LEAVE, vec![], at_ms)
111}
112
113pub fn build_kick_rumor(
118 admin: PublicKey,
119 target: PublicKey,
120 citation: Option<&AuthorityCitation>,
121 at_ms: u64,
122) -> UnsignedEvent {
123 let mut tags = vec![Tag::public_key(target)];
124 if let Some(c) = citation {
125 tags.push(c.to_tag());
126 }
127 stream::build_rumor_ms(kind::KICK, admin, "", tags, at_ms)
128}
129
130pub fn build_snapshot_rumors(
137 refounder: PublicKey,
138 members: &[PublicKey],
139 snapshot_id: [u8; 32],
140 at_ms: u64,
141) -> Vec<UnsignedEvent> {
142 let id_hex = crate::simd::hex::bytes_to_hex_32(&snapshot_id);
143 let chunks: Vec<&[PublicKey]> = if members.is_empty() {
144 vec![&[]]
145 } else {
146 members.chunks(SNAPSHOT_CHUNK).collect()
147 };
148 let n = chunks.len();
149 chunks
150 .iter()
151 .enumerate()
152 .map(|(idx, chunk)| {
153 let hexes: Vec<String> = chunk.iter().map(|p| p.to_hex()).collect();
154 let content = serde_json::to_string(&hexes).expect("a string array always serializes");
155 let tags = vec![Tag::custom(
156 TAG_SNAP,
157 [id_hex.clone(), (idx + 1).to_string(), n.to_string()],
158 )];
159 stream::build_rumor_ms(kind::SNAPSHOT, refounder, &content, tags, at_ms)
160 })
161 .collect()
162}
163
164pub fn seal_guestbook_rumor(
168 rumor: &UnsignedEvent,
169 group: &GroupKey,
170 author_keys: &Keys,
171 wrap_at: Timestamp,
172) -> Result<(Event, Keys), GuestbookError> {
173 let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
174 Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
175}
176
177pub async fn seal_guestbook_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
181 signer: &S,
182 author: PublicKey,
183 rumor: &UnsignedEvent,
184 group: &GroupKey,
185 wrap_at: Timestamp,
186) -> Result<(Event, Keys), GuestbookError> {
187 Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, stream::KIND_WRAP, wrap_at, &[]).await?)
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
197pub struct GuestbookEvent {
198 pub rumor_id: [u8; 32],
200 pub entry: GuestbookEntry,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
207pub enum GuestbookEntry {
208 Join {
209 member: PublicKey,
210 invited_by: Option<(String, String)>,
213 at_ms: u64,
214 },
215 Leave {
216 member: PublicKey,
217 at_ms: u64,
218 },
219 Kick {
220 actor: PublicKey,
221 target: PublicKey,
222 citation: Option<AuthorityCitation>,
226 at_ms: u64,
227 },
228 Snapshot {
229 refounder: PublicKey,
230 members: Vec<PublicKey>,
231 snapshot_id: [u8; 32],
232 chunk: (u32, u32),
234 at_ms: u64,
235 },
236}
237
238impl GuestbookEntry {
239 pub fn at_ms(&self) -> u64 {
241 match self {
242 GuestbookEntry::Join { at_ms, .. }
243 | GuestbookEntry::Leave { at_ms, .. }
244 | GuestbookEntry::Kick { at_ms, .. }
245 | GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
246 }
247 }
248}
249
250pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
255 if opened.seal_form != SealForm::Encrypted {
256 return Err(GuestbookError::NotEncryptedSealed);
257 }
258 let rumor = &opened.rumor;
259 let at_ms = opened.at_ms;
260
261 let entry = match rumor.kind.as_u16() {
262 kind::JOIN_LEAVE => match rumor.content.as_str() {
263 VERB_JOIN => {
264 let invited_by = rumor.tags.iter().find_map(|t| {
265 let s = t.as_slice();
266 (s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
267 });
268 GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
269 }
270 VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
271 _ => return Err(GuestbookError::BadVerb),
272 },
273 kind::KICK => {
274 let mut target: Option<PublicKey> = None;
277 for t in rumor.tags.iter() {
278 let s = t.as_slice();
279 if s.len() >= 2 && s[0] == TAG_TARGET {
280 if target.is_some() {
281 return Err(GuestbookError::DuplicateTag(TAG_TARGET));
282 }
283 target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
284 }
285 }
286 let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
287 GuestbookEntry::Kick {
288 actor: opened.author,
289 target,
290 citation: AuthorityCitation::from_tags(&rumor.tags),
291 at_ms,
292 }
293 }
294 kind::SNAPSHOT => {
295 let mut snap: Option<(String, String, String)> = None;
296 for t in rumor.tags.iter() {
297 let s = t.as_slice();
298 if s.len() >= 2 && s[0] == TAG_SNAP {
299 if snap.is_some() {
300 return Err(GuestbookError::DuplicateTag(TAG_SNAP));
301 }
302 if s.len() < 4 {
303 return Err(GuestbookError::BadTag(TAG_SNAP));
304 }
305 snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
306 }
307 }
308 let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
311 if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
312 return Err(GuestbookError::BadTag(TAG_SNAP));
313 }
314 let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
315 let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
316 let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
317 if i < 1 || i > n {
318 return Err(GuestbookError::BadTag(TAG_SNAP));
319 }
320 let raw: Vec<serde_json::Value> =
321 serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
322 let members = raw
327 .iter()
328 .filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
329 .collect();
330 GuestbookEntry::Snapshot {
331 refounder: opened.author,
332 members,
333 snapshot_id,
334 chunk: (i, n),
335 at_ms,
336 }
337 }
338 k => return Err(GuestbookError::NotGuestbook(k)),
339 };
340
341 Ok(GuestbookEvent { rumor_id: opened.rumor_id.to_bytes(), entry })
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum Verdict {
349 Joined,
350 Left,
351 Kicked,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum Source {
358 Firsthand,
359 Snapshot,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct MemberState {
365 pub verdict: Verdict,
366 pub at_ms: u64,
368 pub source: Source,
369 pub invited_by: Option<(String, String)>,
371 pub rumor_id: [u8; 32],
373}
374
375fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
381 if next.at_ms != prev.at_ms {
382 return next.at_ms > prev.at_ms;
383 }
384 if next.source != prev.source {
385 return next.source == Source::Firsthand;
386 }
387 next.rumor_id < prev.rumor_id
388}
389
390fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
391 match fold.get(&member) {
392 Some(prev) if !supersedes(prev, &next) => {}
393 _ => {
394 fold.insert(member, next);
395 }
396 }
397}
398
399pub fn coalesce(
417 events: &[GuestbookEvent],
418 now_ms: u64,
419 snapshot_authority: Option<&PublicKey>,
420 can_kick: &dyn Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
421) -> BTreeMap<PublicKey, MemberState> {
422 let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
423 let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
424
425 for ev in events {
426 if ev.entry.at_ms() > horizon {
427 continue;
428 }
429 match &ev.entry {
430 GuestbookEntry::Join { member, invited_by, at_ms } => apply(
431 &mut fold,
432 *member,
433 MemberState {
434 verdict: Verdict::Joined,
435 at_ms: *at_ms,
436 source: Source::Firsthand,
437 invited_by: invited_by.clone(),
438 rumor_id: ev.rumor_id,
439 },
440 ),
441 GuestbookEntry::Leave { member, at_ms } => apply(
442 &mut fold,
443 *member,
444 MemberState {
445 verdict: Verdict::Left,
446 at_ms: *at_ms,
447 source: Source::Firsthand,
448 invited_by: None,
449 rumor_id: ev.rumor_id,
450 },
451 ),
452 GuestbookEntry::Kick { actor, target, at_ms, citation } => {
453 if !can_kick(actor, target, citation.as_ref()) {
454 continue;
455 }
456 apply(
457 &mut fold,
458 *target,
459 MemberState {
460 verdict: Verdict::Kicked,
461 at_ms: *at_ms,
462 source: Source::Firsthand,
463 invited_by: None,
464 rumor_id: ev.rumor_id,
465 },
466 );
467 }
468 GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
469 if snapshot_authority != Some(refounder) {
470 continue;
471 }
472 for m in members {
481 apply(
482 &mut fold,
483 *m,
484 MemberState {
485 verdict: Verdict::Joined,
486 at_ms: *at_ms,
487 source: Source::Snapshot,
488 invited_by: None,
489 rumor_id: ev.rumor_id,
490 },
491 );
492 }
493 }
494 }
495 }
496
497 fold
498}
499
500pub fn complete_memberlist(
510 coalesced: &BTreeMap<PublicKey, MemberState>,
511 observed: &BTreeMap<PublicKey, u64>,
512 banlist: &BTreeSet<PublicKey>,
513 banned_at: &BTreeMap<PublicKey, u64>,
514) -> BTreeSet<PublicKey> {
515 let stale_pre_ban = |pk: &PublicKey, ms: u64| banned_at.get(pk).is_some_and(|at| ms <= at.saturating_mul(1000));
524 let mut out = BTreeSet::new();
525 for (pk, st) in coalesced {
526 if st.verdict == Verdict::Joined && !banlist.contains(pk) && !stale_pre_ban(pk, st.at_ms) {
527 out.insert(*pk);
528 }
529 }
530 for (pk, seen_ms) in observed {
531 if banlist.contains(pk) || stale_pre_ban(pk, *seen_ms) {
532 continue;
533 }
534 match coalesced.get(pk) {
535 Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
536 _ => {
537 out.insert(*pk);
538 }
539 }
540 }
541 out
542}
543
544#[cfg(test)]
545mod tests {
546 use super::super::super::{CommunityId, Epoch};
547 use super::super::derive::guestbook_group_key;
548 use super::*;
549
550 const NOW: u64 = 1_722_500_000_000;
552
553 fn cid() -> CommunityId {
554 CommunityId([0x33; 32])
555 }
556
557 fn group() -> GroupKey {
558 guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
559 }
560
561 fn pk() -> PublicKey {
562 Keys::generate().public_key()
563 }
564
565 fn always(_: &PublicKey, _: &PublicKey, _: Option<&AuthorityCitation>) -> bool {
566 true
567 }
568
569 fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
571 let g = group();
572 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
573 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
574 }
575
576 fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
577 let g = group();
578 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
579 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
580 }
581
582 fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
585 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
586 }
587
588 fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
589 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
590 }
591
592 fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
593 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
594 }
595
596 fn snap_ev(
597 refounder: PublicKey,
598 members: Vec<PublicKey>,
599 snap: u8,
600 chunk: (u32, u32),
601 at_ms: u64,
602 id: u8,
603 ) -> GuestbookEvent {
604 GuestbookEvent {
605 rumor_id: [id; 32],
606 entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
607 }
608 }
609
610 #[test]
611 fn join_and_leave_round_trip_through_the_stream() {
612 let member = Keys::generate();
613 let creator = pk().to_hex();
614
615 let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
616 let ev = through(&join, &member);
617 assert_eq!(
618 ev.entry,
619 GuestbookEntry::Join {
620 member: member.public_key(),
621 invited_by: Some((creator, "Reddit".into())),
622 at_ms: 1_722_400_000_128,
623 }
624 );
625
626 let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
628 assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
629 let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
630 assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
631 }
632
633 #[test]
634 fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
635 let admin = Keys::generate();
636 let target = pk();
637 let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
638 let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
639
640 let g = group();
641 let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
642 let opened = stream::open_wrap(&wrap, &g).unwrap();
643 let ev = parse_guestbook_event(&opened).unwrap();
644
645 assert_eq!(
646 ev.entry,
647 GuestbookEntry::Kick {
648 actor: admin.public_key(),
649 target,
650 citation: Some(cite),
651 at_ms: 1_722_410_000_301,
652 }
653 );
654 assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
657 assert_ne!(ev.rumor_id, wrap.id.to_bytes());
658
659 let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
661 assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
662 }
663
664 #[test]
665 fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
666 let refounder = Keys::generate();
667 let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
668 let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
669 assert_eq!(rumors.len(), 2);
670
671 let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
672 let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
673 GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
674 (&parsed[0].entry, &parsed[1].entry)
675 else {
676 panic!("expected two snapshot entries");
677 };
678 assert_eq!((m1.len(), m2.len()), (400, 1));
679 assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
680 assert_eq!(id1, &[0x5a; 32]);
681 assert_eq!(id1, id2);
682 assert_eq!(t1, t2, "all chunks share one timestamp");
683 assert_eq!(*r1, refounder.public_key());
684 let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
685 assert_eq!(all, members, "membership survives the chunking intact");
686
687 let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
689 assert_eq!(empty.len(), 1);
690 let ev = through(&empty[0], &refounder);
691 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
692 }
693
694 #[test]
695 fn plaintext_sealed_guestbook_events_are_rejected() {
696 let member = Keys::generate();
699 let g = group();
700 let rumor = build_join_rumor(member.public_key(), None, 1_000);
701 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
702 let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
703 let opened = stream::open_wrap(&wrap, &g).unwrap();
704 assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
705 }
706
707 #[test]
708 fn future_dated_entries_are_dropped_outright() {
709 let m = pk();
710 let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
713 let ok = leave_ev(m, NOW + 59 * 60_000, 2);
714 let fold = coalesce(&[squat, ok], NOW, None, &always);
715 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
716
717 let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
719 let fold = coalesce(&[edge], NOW, None, &always);
720 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
721 }
722
723 #[test]
724 fn latest_wins_per_npub_join_leave_rejoin() {
725 let m = pk();
726 let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
727
728 let fold = coalesce(&evs, NOW, None, &always);
729 let st = fold.get(&m).unwrap();
730 assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
731 assert_eq!(st.at_ms, 3_000);
732
733 let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
735 assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
736
737 let fold = coalesce(&evs[..2], NOW, None, &always);
739 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
740 }
741
742 #[test]
743 fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
744 let m = pk();
745 let auth = pk();
746
747 let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
750 let leave = leave_ev(m, 5_000, 0xff);
751 for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
752 let fold = coalesce(&evs, NOW, Some(&auth), &always);
753 let st = fold.get(&m).unwrap();
754 assert_eq!(st.verdict, Verdict::Left);
755 assert_eq!(st.source, Source::Firsthand);
756 }
757
758 let join = join_ev(m, 6_000, 0x01);
760 let leave = leave_ev(m, 6_000, 0x02);
761 for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
762 let fold = coalesce(&evs, NOW, None, &always);
763 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
764 }
765 let join = join_ev(m, 7_000, 0x02);
767 let leave = leave_ev(m, 7_000, 0x01);
768 let fold = coalesce(&[join, leave], NOW, None, &always);
769 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
770 }
771
772 #[test]
773 fn kick_needs_the_callers_authority_verdict() {
774 let admin = pk();
775 let m = pk();
776 let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
777
778 let denied = coalesce(&evs, NOW, None, &|_, _, _| false);
779 assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
780
781 let granted = coalesce(&evs, NOW, None, &|actor, target, _| *actor == admin && *target == m);
782 let st = granted.get(&m).unwrap();
783 assert_eq!(st.verdict, Verdict::Kicked);
784 assert_eq!(st.at_ms, 2_000);
785 }
786
787 #[test]
788 fn snapshots_are_honored_only_from_the_epochs_refounder() {
789 let refounder = pk();
790 let impostor = pk();
791 let m = pk();
792
793 let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
795 assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
796
797 let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
799 assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
800
801 let fold = coalesce(&[real], NOW, Some(&refounder), &always);
803 let st = fold.get(&m).unwrap();
804 assert_eq!(st.verdict, Verdict::Joined);
805 assert_eq!(st.source, Source::Snapshot);
806 assert_eq!(st.at_ms, 5_000);
807 }
808
809 #[test]
810 fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
811 let refounder = pk();
812 let m = pk();
813 let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
814
815 let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
817 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
818
819 let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
821 let st = fold.get(&m).unwrap();
822 assert_eq!(st.source, Source::Snapshot);
823 assert_eq!(st.at_ms, 5_000);
824 }
825
826 #[test]
827 fn a_torn_snapshot_coalesces_order_independently() {
828 let refounder = pk();
835 let (a, b, c) = (pk(), pk(), pk());
836 let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
837 let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
838 let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
839
840 let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
841 let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
842 for m in [&a, &b, &c] {
844 assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
845 }
846 assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
847 }
848
849 #[test]
850 fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
851 let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
852 let coalesced = coalesce(
853 &[
854 join_ev(joined, 1_000, 1),
855 join_ev(left, 1_000, 2),
856 leave_ev(left, 5_000, 3),
857 join_ev(banned_joined, 1_000, 4),
858 ],
859 NOW,
860 None,
861 &always,
862 );
863 let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
864
865 let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
868 let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new());
869 assert!(list.contains(&joined));
870 assert!(list.contains(&silent), "an observed author with no guestbook state is present");
871 assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
872 assert!(!list.contains(&banned_joined));
873 assert!(!list.contains(&banned_observed));
874
875 let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
877 let list = complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new());
878 assert!(list.contains(&left));
879 let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
881 assert!(!complete_memberlist(&coalesced, &observed, &banlist, &BTreeMap::new()).contains(&left));
882 }
883
884 #[test]
885 fn an_unban_does_not_resurrect_a_pre_ban_join_or_activity() {
886 let (member, chatty) = (pk(), pk());
890 let coalesced = coalesce(&[join_ev(member, 1_000, 1), join_ev(chatty, 1_000, 2)], NOW, None, &always);
891 let banned_at: BTreeMap<PublicKey, u64> = [(member, 5), (chatty, 5)].into();
893 let empty: BTreeSet<PublicKey> = BTreeSet::new();
894
895 let observed: BTreeMap<PublicKey, u64> = [(chatty, 4_000)].into();
896 let list = complete_memberlist(&coalesced, &observed, &empty, &banned_at);
897 assert!(!list.contains(&member), "a pre-ban Join must not survive the un-ban");
898 assert!(!list.contains(&chatty), "pre-ban activity must not survive the un-ban either");
899
900 let observed: BTreeMap<PublicKey, u64> = [(chatty, 6_000)].into();
902 assert!(
903 complete_memberlist(&coalesced, &observed, &empty, &banned_at).contains(&chatty),
904 "activity newer than the ban is a real rejoin"
905 );
906 let rejoined = coalesce(&[join_ev(member, 1_000, 1), join_ev(member, 6_000, 3)], NOW, None, &always);
907 assert!(
908 complete_memberlist(&rejoined, &BTreeMap::new(), &empty, &banned_at).contains(&member),
909 "a Join newer than the ban re-admits"
910 );
911 let banlist: BTreeSet<PublicKey> = [member].into();
913 assert!(!complete_memberlist(&rejoined, &BTreeMap::new(), &banlist, &banned_at).contains(&member));
914 }
915
916 #[test]
917 fn malformed_verbs_and_foreign_kinds_are_rejected() {
918 let k = Keys::generate();
919 for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
922 let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
923 assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
924 }
925 let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
926 assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
927 }
928
929 #[test]
930 fn kick_target_must_be_a_unique_valid_p_tag() {
931 let k = Keys::generate();
932
933 let dup = stream::build_rumor_ms(
934 kind::KICK,
935 k.public_key(),
936 "",
937 vec![Tag::public_key(pk()), Tag::public_key(pk())],
938 1_000,
939 );
940 assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
941
942 let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
943 assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
944
945 let bad = stream::build_rumor_ms(
946 kind::KICK,
947 k.public_key(),
948 "",
949 vec![Tag::custom("p", ["not-hex".to_string()])],
950 1_000,
951 );
952 assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
953 }
954
955 #[test]
956 fn snapshot_snap_tag_and_content_are_strict() {
957 let k = Keys::generate();
958 let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
959 let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
960 stream::build_rumor_ms(
961 kind::SNAPSHOT,
962 k.public_key(),
963 content,
964 vec![Tag::custom(
965 "snap",
966 [id.to_string(), i.to_string(), n.to_string()],
967 )],
968 1_000,
969 )
970 };
971
972 for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
974 let r = snap_rumor("[]", &id_hex, i, n);
975 assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
976 }
977 let bad_id = snap_rumor("[]", "zz", "1", "1");
979 assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
980 let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
981 assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
982 let not_array = snap_rumor("{}", &id_hex, "1", "1");
983 assert!(matches!(through_err(¬_array, &k), GuestbookError::BadSnapshotContent));
984 }
985
986 #[test]
987 fn snapshot_bad_member_entries_drop_individually() {
988 let k = Keys::generate();
989 let good = pk();
990 let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
993 let rumor = stream::build_rumor_ms(
994 kind::SNAPSHOT,
995 k.public_key(),
996 &content,
997 vec![Tag::custom(
998 "snap",
999 [crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
1000 )],
1001 1_000,
1002 );
1003 let ev = through(&rumor, &k);
1004 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
1005 }
1006}