1use std::collections::{BTreeMap, BTreeSet};
22
23use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, TagKind, 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 TagKind::Custom(TAG_INVITE.into()),
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 TagKind::Custom(TAG_SNAP.into()),
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
177#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
184pub struct GuestbookEvent {
185 pub rumor_id: [u8; 32],
187 pub entry: GuestbookEntry,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
194pub enum GuestbookEntry {
195 Join {
196 member: PublicKey,
197 invited_by: Option<(String, String)>,
200 at_ms: u64,
201 },
202 Leave {
203 member: PublicKey,
204 at_ms: u64,
205 },
206 Kick {
207 actor: PublicKey,
208 target: PublicKey,
209 citation: Option<AuthorityCitation>,
213 at_ms: u64,
214 },
215 Snapshot {
216 refounder: PublicKey,
217 members: Vec<PublicKey>,
218 snapshot_id: [u8; 32],
219 chunk: (u32, u32),
221 at_ms: u64,
222 },
223}
224
225impl GuestbookEntry {
226 pub fn at_ms(&self) -> u64 {
228 match self {
229 GuestbookEntry::Join { at_ms, .. }
230 | GuestbookEntry::Leave { at_ms, .. }
231 | GuestbookEntry::Kick { at_ms, .. }
232 | GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
233 }
234 }
235}
236
237pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
242 if opened.seal_form != SealForm::Encrypted {
243 return Err(GuestbookError::NotEncryptedSealed);
244 }
245 let rumor = &opened.rumor;
246 let at_ms = opened.at_ms;
247
248 let entry = match rumor.kind.as_u16() {
249 kind::JOIN_LEAVE => match rumor.content.as_str() {
250 VERB_JOIN => {
251 let invited_by = rumor.tags.iter().find_map(|t| {
252 let s = t.as_slice();
253 (s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
254 });
255 GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
256 }
257 VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
258 _ => return Err(GuestbookError::BadVerb),
259 },
260 kind::KICK => {
261 let mut target: Option<PublicKey> = None;
264 for t in rumor.tags.iter() {
265 let s = t.as_slice();
266 if s.len() >= 2 && s[0] == TAG_TARGET {
267 if target.is_some() {
268 return Err(GuestbookError::DuplicateTag(TAG_TARGET));
269 }
270 target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
271 }
272 }
273 let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
274 GuestbookEntry::Kick {
275 actor: opened.author,
276 target,
277 citation: AuthorityCitation::from_tags(&rumor.tags),
278 at_ms,
279 }
280 }
281 kind::SNAPSHOT => {
282 let mut snap: Option<(String, String, String)> = None;
283 for t in rumor.tags.iter() {
284 let s = t.as_slice();
285 if s.len() >= 2 && s[0] == TAG_SNAP {
286 if snap.is_some() {
287 return Err(GuestbookError::DuplicateTag(TAG_SNAP));
288 }
289 if s.len() < 4 {
290 return Err(GuestbookError::BadTag(TAG_SNAP));
291 }
292 snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
293 }
294 }
295 let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
298 if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
299 return Err(GuestbookError::BadTag(TAG_SNAP));
300 }
301 let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
302 let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
303 let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
304 if i < 1 || i > n {
305 return Err(GuestbookError::BadTag(TAG_SNAP));
306 }
307 let raw: Vec<serde_json::Value> =
308 serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
309 let members = raw
314 .iter()
315 .filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
316 .collect();
317 GuestbookEntry::Snapshot {
318 refounder: opened.author,
319 members,
320 snapshot_id,
321 chunk: (i, n),
322 at_ms,
323 }
324 }
325 k => return Err(GuestbookError::NotGuestbook(k)),
326 };
327
328 Ok(GuestbookEvent { rumor_id: opened.rumor_id.to_bytes(), entry })
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum Verdict {
336 Joined,
337 Left,
338 Kicked,
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum Source {
345 Firsthand,
346 Snapshot,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct MemberState {
352 pub verdict: Verdict,
353 pub at_ms: u64,
355 pub source: Source,
356 pub invited_by: Option<(String, String)>,
358 pub rumor_id: [u8; 32],
360}
361
362fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
368 if next.at_ms != prev.at_ms {
369 return next.at_ms > prev.at_ms;
370 }
371 if next.source != prev.source {
372 return next.source == Source::Firsthand;
373 }
374 next.rumor_id < prev.rumor_id
375}
376
377fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
378 match fold.get(&member) {
379 Some(prev) if !supersedes(prev, &next) => {}
380 _ => {
381 fold.insert(member, next);
382 }
383 }
384}
385
386pub fn coalesce(
404 events: &[GuestbookEvent],
405 now_ms: u64,
406 snapshot_authority: Option<&PublicKey>,
407 can_kick: &dyn Fn(&PublicKey, &PublicKey) -> bool,
408) -> BTreeMap<PublicKey, MemberState> {
409 let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
410 let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
411
412 for ev in events {
413 if ev.entry.at_ms() > horizon {
414 continue;
415 }
416 match &ev.entry {
417 GuestbookEntry::Join { member, invited_by, at_ms } => apply(
418 &mut fold,
419 *member,
420 MemberState {
421 verdict: Verdict::Joined,
422 at_ms: *at_ms,
423 source: Source::Firsthand,
424 invited_by: invited_by.clone(),
425 rumor_id: ev.rumor_id,
426 },
427 ),
428 GuestbookEntry::Leave { member, at_ms } => apply(
429 &mut fold,
430 *member,
431 MemberState {
432 verdict: Verdict::Left,
433 at_ms: *at_ms,
434 source: Source::Firsthand,
435 invited_by: None,
436 rumor_id: ev.rumor_id,
437 },
438 ),
439 GuestbookEntry::Kick { actor, target, at_ms, .. } => {
440 if !can_kick(actor, target) {
441 continue;
442 }
443 apply(
444 &mut fold,
445 *target,
446 MemberState {
447 verdict: Verdict::Kicked,
448 at_ms: *at_ms,
449 source: Source::Firsthand,
450 invited_by: None,
451 rumor_id: ev.rumor_id,
452 },
453 );
454 }
455 GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
456 if snapshot_authority != Some(refounder) {
457 continue;
458 }
459 for m in members {
468 apply(
469 &mut fold,
470 *m,
471 MemberState {
472 verdict: Verdict::Joined,
473 at_ms: *at_ms,
474 source: Source::Snapshot,
475 invited_by: None,
476 rumor_id: ev.rumor_id,
477 },
478 );
479 }
480 }
481 }
482 }
483
484 fold
485}
486
487pub fn complete_memberlist(
497 coalesced: &BTreeMap<PublicKey, MemberState>,
498 observed: &BTreeMap<PublicKey, u64>,
499 banlist: &BTreeSet<PublicKey>,
500) -> BTreeSet<PublicKey> {
501 let mut out = BTreeSet::new();
502 for (pk, st) in coalesced {
503 if st.verdict == Verdict::Joined && !banlist.contains(pk) {
504 out.insert(*pk);
505 }
506 }
507 for (pk, seen_ms) in observed {
508 if banlist.contains(pk) {
509 continue;
510 }
511 match coalesced.get(pk) {
512 Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
513 _ => {
514 out.insert(*pk);
515 }
516 }
517 }
518 out
519}
520
521#[cfg(test)]
522mod tests {
523 use super::super::super::{CommunityId, Epoch};
524 use super::super::derive::guestbook_group_key;
525 use super::*;
526
527 const NOW: u64 = 1_722_500_000_000;
529
530 fn cid() -> CommunityId {
531 CommunityId([0x33; 32])
532 }
533
534 fn group() -> GroupKey {
535 guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
536 }
537
538 fn pk() -> PublicKey {
539 Keys::generate().public_key()
540 }
541
542 fn always(_: &PublicKey, _: &PublicKey) -> bool {
543 true
544 }
545
546 fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
548 let g = group();
549 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
550 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
551 }
552
553 fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
554 let g = group();
555 let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
556 parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
557 }
558
559 fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
562 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
563 }
564
565 fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
566 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
567 }
568
569 fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
570 GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
571 }
572
573 fn snap_ev(
574 refounder: PublicKey,
575 members: Vec<PublicKey>,
576 snap: u8,
577 chunk: (u32, u32),
578 at_ms: u64,
579 id: u8,
580 ) -> GuestbookEvent {
581 GuestbookEvent {
582 rumor_id: [id; 32],
583 entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
584 }
585 }
586
587 #[test]
588 fn join_and_leave_round_trip_through_the_stream() {
589 let member = Keys::generate();
590 let creator = pk().to_hex();
591
592 let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
593 let ev = through(&join, &member);
594 assert_eq!(
595 ev.entry,
596 GuestbookEntry::Join {
597 member: member.public_key(),
598 invited_by: Some((creator, "Reddit".into())),
599 at_ms: 1_722_400_000_128,
600 }
601 );
602
603 let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
605 assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
606 let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
607 assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
608 }
609
610 #[test]
611 fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
612 let admin = Keys::generate();
613 let target = pk();
614 let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
615 let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
616
617 let g = group();
618 let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
619 let opened = stream::open_wrap(&wrap, &g).unwrap();
620 let ev = parse_guestbook_event(&opened).unwrap();
621
622 assert_eq!(
623 ev.entry,
624 GuestbookEntry::Kick {
625 actor: admin.public_key(),
626 target,
627 citation: Some(cite),
628 at_ms: 1_722_410_000_301,
629 }
630 );
631 assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
634 assert_ne!(ev.rumor_id, wrap.id.to_bytes());
635
636 let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
638 assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
639 }
640
641 #[test]
642 fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
643 let refounder = Keys::generate();
644 let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
645 let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
646 assert_eq!(rumors.len(), 2);
647
648 let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
649 let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
650 GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
651 (&parsed[0].entry, &parsed[1].entry)
652 else {
653 panic!("expected two snapshot entries");
654 };
655 assert_eq!((m1.len(), m2.len()), (400, 1));
656 assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
657 assert_eq!(id1, &[0x5a; 32]);
658 assert_eq!(id1, id2);
659 assert_eq!(t1, t2, "all chunks share one timestamp");
660 assert_eq!(*r1, refounder.public_key());
661 let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
662 assert_eq!(all, members, "membership survives the chunking intact");
663
664 let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
666 assert_eq!(empty.len(), 1);
667 let ev = through(&empty[0], &refounder);
668 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
669 }
670
671 #[test]
672 fn plaintext_sealed_guestbook_events_are_rejected() {
673 let member = Keys::generate();
676 let g = group();
677 let rumor = build_join_rumor(member.public_key(), None, 1_000);
678 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
679 let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
680 let opened = stream::open_wrap(&wrap, &g).unwrap();
681 assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
682 }
683
684 #[test]
685 fn future_dated_entries_are_dropped_outright() {
686 let m = pk();
687 let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
690 let ok = leave_ev(m, NOW + 59 * 60_000, 2);
691 let fold = coalesce(&[squat, ok], NOW, None, &always);
692 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
693
694 let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
696 let fold = coalesce(&[edge], NOW, None, &always);
697 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
698 }
699
700 #[test]
701 fn latest_wins_per_npub_join_leave_rejoin() {
702 let m = pk();
703 let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
704
705 let fold = coalesce(&evs, NOW, None, &always);
706 let st = fold.get(&m).unwrap();
707 assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
708 assert_eq!(st.at_ms, 3_000);
709
710 let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
712 assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
713
714 let fold = coalesce(&evs[..2], NOW, None, &always);
716 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
717 }
718
719 #[test]
720 fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
721 let m = pk();
722 let auth = pk();
723
724 let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
727 let leave = leave_ev(m, 5_000, 0xff);
728 for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
729 let fold = coalesce(&evs, NOW, Some(&auth), &always);
730 let st = fold.get(&m).unwrap();
731 assert_eq!(st.verdict, Verdict::Left);
732 assert_eq!(st.source, Source::Firsthand);
733 }
734
735 let join = join_ev(m, 6_000, 0x01);
737 let leave = leave_ev(m, 6_000, 0x02);
738 for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
739 let fold = coalesce(&evs, NOW, None, &always);
740 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
741 }
742 let join = join_ev(m, 7_000, 0x02);
744 let leave = leave_ev(m, 7_000, 0x01);
745 let fold = coalesce(&[join, leave], NOW, None, &always);
746 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
747 }
748
749 #[test]
750 fn kick_needs_the_callers_authority_verdict() {
751 let admin = pk();
752 let m = pk();
753 let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
754
755 let denied = coalesce(&evs, NOW, None, &|_, _| false);
756 assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
757
758 let granted = coalesce(&evs, NOW, None, &|actor, target| *actor == admin && *target == m);
759 let st = granted.get(&m).unwrap();
760 assert_eq!(st.verdict, Verdict::Kicked);
761 assert_eq!(st.at_ms, 2_000);
762 }
763
764 #[test]
765 fn snapshots_are_honored_only_from_the_epochs_refounder() {
766 let refounder = pk();
767 let impostor = pk();
768 let m = pk();
769
770 let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
772 assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
773
774 let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
776 assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
777
778 let fold = coalesce(&[real], NOW, Some(&refounder), &always);
780 let st = fold.get(&m).unwrap();
781 assert_eq!(st.verdict, Verdict::Joined);
782 assert_eq!(st.source, Source::Snapshot);
783 assert_eq!(st.at_ms, 5_000);
784 }
785
786 #[test]
787 fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
788 let refounder = pk();
789 let m = pk();
790 let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
791
792 let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
794 assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
795
796 let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
798 let st = fold.get(&m).unwrap();
799 assert_eq!(st.source, Source::Snapshot);
800 assert_eq!(st.at_ms, 5_000);
801 }
802
803 #[test]
804 fn a_torn_snapshot_coalesces_order_independently() {
805 let refounder = pk();
812 let (a, b, c) = (pk(), pk(), pk());
813 let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
814 let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
815 let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
816
817 let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
818 let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
819 for m in [&a, &b, &c] {
821 assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
822 }
823 assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
824 }
825
826 #[test]
827 fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
828 let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
829 let coalesced = coalesce(
830 &[
831 join_ev(joined, 1_000, 1),
832 join_ev(left, 1_000, 2),
833 leave_ev(left, 5_000, 3),
834 join_ev(banned_joined, 1_000, 4),
835 ],
836 NOW,
837 None,
838 &always,
839 );
840 let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
841
842 let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
845 let list = complete_memberlist(&coalesced, &observed, &banlist);
846 assert!(list.contains(&joined));
847 assert!(list.contains(&silent), "an observed author with no guestbook state is present");
848 assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
849 assert!(!list.contains(&banned_joined));
850 assert!(!list.contains(&banned_observed));
851
852 let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
854 let list = complete_memberlist(&coalesced, &observed, &banlist);
855 assert!(list.contains(&left));
856 let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
858 assert!(!complete_memberlist(&coalesced, &observed, &banlist).contains(&left));
859 }
860
861 #[test]
862 fn malformed_verbs_and_foreign_kinds_are_rejected() {
863 let k = Keys::generate();
864 for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
867 let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
868 assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
869 }
870 let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
871 assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
872 }
873
874 #[test]
875 fn kick_target_must_be_a_unique_valid_p_tag() {
876 let k = Keys::generate();
877
878 let dup = stream::build_rumor_ms(
879 kind::KICK,
880 k.public_key(),
881 "",
882 vec![Tag::public_key(pk()), Tag::public_key(pk())],
883 1_000,
884 );
885 assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
886
887 let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
888 assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
889
890 let bad = stream::build_rumor_ms(
891 kind::KICK,
892 k.public_key(),
893 "",
894 vec![Tag::custom(TagKind::Custom("p".into()), ["not-hex".to_string()])],
895 1_000,
896 );
897 assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
898 }
899
900 #[test]
901 fn snapshot_snap_tag_and_content_are_strict() {
902 let k = Keys::generate();
903 let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
904 let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
905 stream::build_rumor_ms(
906 kind::SNAPSHOT,
907 k.public_key(),
908 content,
909 vec![Tag::custom(
910 TagKind::Custom("snap".into()),
911 [id.to_string(), i.to_string(), n.to_string()],
912 )],
913 1_000,
914 )
915 };
916
917 for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
919 let r = snap_rumor("[]", &id_hex, i, n);
920 assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
921 }
922 let bad_id = snap_rumor("[]", "zz", "1", "1");
924 assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
925 let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
926 assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
927 let not_array = snap_rumor("{}", &id_hex, "1", "1");
928 assert!(matches!(through_err(¬_array, &k), GuestbookError::BadSnapshotContent));
929 }
930
931 #[test]
932 fn snapshot_bad_member_entries_drop_individually() {
933 let k = Keys::generate();
934 let good = pk();
935 let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
938 let rumor = stream::build_rumor_ms(
939 kind::SNAPSHOT,
940 k.public_key(),
941 &content,
942 vec![Tag::custom(
943 TagKind::Custom("snap".into()),
944 [crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
945 )],
946 1_000,
947 );
948 let ev = through(&rumor, &k);
949 assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
950 }
951}