use std::collections::{BTreeMap, BTreeSet};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
use super::super::edition::AuthorityCitation;
use super::derive::GroupKey;
use super::kind;
use super::stream::{self, OpenedStream, SealForm, StreamError};
pub const MAX_FUTURE_MS: u64 = 3_600_000;
pub const SNAPSHOT_CHUNK: usize = 400;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const VERB_JOIN: &str = "join";
const VERB_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotGuestbook(u16),
NotEncryptedSealed,
BadVerb,
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
BadSnapshotContent,
}
impl std::fmt::Display for GuestbookError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GuestbookError::Stream(e) => write!(f, "stream: {e}"),
GuestbookError::NotGuestbook(k) => write!(f, "rumor kind {k} is not a guestbook event"),
GuestbookError::NotEncryptedSealed => write!(f, "guestbook events must ride an encrypted seal"),
GuestbookError::BadVerb => write!(f, "3306 content is not exactly join/leave"),
GuestbookError::MissingTag(t) => write!(f, "missing guestbook tag: {t}"),
GuestbookError::DuplicateTag(t) => write!(f, "duplicate guestbook tag: {t}"),
GuestbookError::BadTag(t) => write!(f, "unparseable guestbook tag: {t}"),
GuestbookError::BadSnapshotContent => write!(f, "snapshot content is not a JSON array"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(e: StreamError) -> Self {
GuestbookError::Stream(e)
}
}
pub fn build_join_rumor(author: PublicKey, invite_attribution: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invite_attribution {
tags.push(Tag::custom(
TagKind::Custom(TAG_INVITE.into()),
[creator.to_string(), label.to_string()],
));
}
stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_JOIN, tags, at_ms)
}
pub fn build_leave_rumor(author: PublicKey, at_ms: u64) -> UnsignedEvent {
stream::build_rumor_ms(kind::JOIN_LEAVE, author, VERB_LEAVE, vec![], at_ms)
}
pub fn build_kick_rumor(
admin: PublicKey,
target: PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::public_key(target)];
if let Some(c) = citation {
tags.push(c.to_tag());
}
stream::build_rumor_ms(kind::KICK, admin, "", tags, at_ms)
}
pub fn build_snapshot_rumors(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let id_hex = crate::simd::hex::bytes_to_hex_32(&snapshot_id);
let chunks: Vec<&[PublicKey]> = if members.is_empty() {
vec![&[]]
} else {
members.chunks(SNAPSHOT_CHUNK).collect()
};
let n = chunks.len();
chunks
.iter()
.enumerate()
.map(|(idx, chunk)| {
let hexes: Vec<String> = chunk.iter().map(|p| p.to_hex()).collect();
let content = serde_json::to_string(&hexes).expect("a string array always serializes");
let tags = vec![Tag::custom(
TagKind::Custom(TAG_SNAP.into()),
[id_hex.clone(), (idx + 1).to_string(), n.to_string()],
)];
stream::build_rumor_ms(kind::SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub fn seal_guestbook_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author_keys: &Keys,
wrap_at: Timestamp,
) -> Result<(Event, Keys), GuestbookError> {
let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
Ok(stream::wrap_seal(&seal, group, stream::KIND_WRAP, wrap_at)?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookEvent {
pub rumor_id: [u8; 32],
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
invited_by: Option<(String, String)>,
at_ms: u64,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
citation: Option<AuthorityCitation>,
at_ms: u64,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
impl GuestbookEntry {
pub fn at_ms(&self) -> u64 {
match self {
GuestbookEntry::Join { at_ms, .. }
| GuestbookEntry::Leave { at_ms, .. }
| GuestbookEntry::Kick { at_ms, .. }
| GuestbookEntry::Snapshot { at_ms, .. } => *at_ms,
}
}
}
pub fn parse_guestbook_event(opened: &OpenedStream) -> Result<GuestbookEvent, GuestbookError> {
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let rumor = &opened.rumor;
let at_ms = opened.at_ms;
let entry = match rumor.kind.as_u16() {
kind::JOIN_LEAVE => match rumor.content.as_str() {
VERB_JOIN => {
let invited_by = rumor.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 3 && s[0] == TAG_INVITE).then(|| (s[1].clone(), s[2].clone()))
});
GuestbookEntry::Join { member: opened.author, invited_by, at_ms }
}
VERB_LEAVE => GuestbookEntry::Leave { member: opened.author, at_ms },
_ => return Err(GuestbookError::BadVerb),
},
kind::KICK => {
let mut target: Option<PublicKey> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == TAG_TARGET {
if target.is_some() {
return Err(GuestbookError::DuplicateTag(TAG_TARGET));
}
target = Some(PublicKey::from_hex(&s[1]).map_err(|_| GuestbookError::BadTag(TAG_TARGET))?);
}
}
let target = target.ok_or(GuestbookError::MissingTag(TAG_TARGET))?;
GuestbookEntry::Kick {
actor: opened.author,
target,
citation: AuthorityCitation::from_tags(&rumor.tags),
at_ms,
}
}
kind::SNAPSHOT => {
let mut snap: Option<(String, String, String)> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == TAG_SNAP {
if snap.is_some() {
return Err(GuestbookError::DuplicateTag(TAG_SNAP));
}
if s.len() < 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
snap = Some((s[1].clone(), s[2].clone(), s[3].clone()));
}
}
let (id_hex, i_raw, n_raw) = snap.ok_or(GuestbookError::MissingTag(TAG_SNAP))?;
if id_hex.len() != 64 || !id_hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = crate::simd::hex::hex_to_bytes_32(&id_hex);
let i: u32 = i_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let n: u32 = n_raw.parse().map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
if i < 1 || i > n {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let raw: Vec<serde_json::Value> =
serde_json::from_str(&rumor.content).map_err(|_| GuestbookError::BadSnapshotContent)?;
let members = raw
.iter()
.filter_map(|v| v.as_str().and_then(|h| PublicKey::from_hex(h).ok()))
.collect();
GuestbookEntry::Snapshot {
refounder: opened.author,
members,
snapshot_id,
chunk: (i, n),
at_ms,
}
}
k => return Err(GuestbookError::NotGuestbook(k)),
};
Ok(GuestbookEvent { rumor_id: opened.rumor_id.to_bytes(), entry })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Joined,
Left,
Kicked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Firsthand,
Snapshot,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberState {
pub verdict: Verdict,
pub at_ms: u64,
pub source: Source,
pub invited_by: Option<(String, String)>,
pub rumor_id: [u8; 32],
}
fn supersedes(prev: &MemberState, next: &MemberState) -> bool {
if next.at_ms != prev.at_ms {
return next.at_ms > prev.at_ms;
}
if next.source != prev.source {
return next.source == Source::Firsthand;
}
next.rumor_id < prev.rumor_id
}
fn apply(fold: &mut BTreeMap<PublicKey, MemberState>, member: PublicKey, next: MemberState) {
match fold.get(&member) {
Some(prev) if !supersedes(prev, &next) => {}
_ => {
fold.insert(member, next);
}
}
}
pub fn coalesce(
events: &[GuestbookEvent],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
can_kick: &dyn Fn(&PublicKey, &PublicKey) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let horizon = now_ms.saturating_add(MAX_FUTURE_MS);
let mut fold: BTreeMap<PublicKey, MemberState> = BTreeMap::new();
for ev in events {
if ev.entry.at_ms() > horizon {
continue;
}
match &ev.entry {
GuestbookEntry::Join { member, invited_by, at_ms } => apply(
&mut fold,
*member,
MemberState {
verdict: Verdict::Joined,
at_ms: *at_ms,
source: Source::Firsthand,
invited_by: invited_by.clone(),
rumor_id: ev.rumor_id,
},
),
GuestbookEntry::Leave { member, at_ms } => apply(
&mut fold,
*member,
MemberState {
verdict: Verdict::Left,
at_ms: *at_ms,
source: Source::Firsthand,
invited_by: None,
rumor_id: ev.rumor_id,
},
),
GuestbookEntry::Kick { actor, target, at_ms, .. } => {
if !can_kick(actor, target) {
continue;
}
apply(
&mut fold,
*target,
MemberState {
verdict: Verdict::Kicked,
at_ms: *at_ms,
source: Source::Firsthand,
invited_by: None,
rumor_id: ev.rumor_id,
},
);
}
GuestbookEntry::Snapshot { refounder, members, at_ms, .. } => {
if snapshot_authority != Some(refounder) {
continue;
}
for m in members {
apply(
&mut fold,
*m,
MemberState {
verdict: Verdict::Joined,
at_ms: *at_ms,
source: Source::Snapshot,
invited_by: None,
rumor_id: ev.rumor_id,
},
);
}
}
}
}
fold
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
banlist: &BTreeSet<PublicKey>,
) -> BTreeSet<PublicKey> {
let mut out = BTreeSet::new();
for (pk, st) in coalesced {
if st.verdict == Verdict::Joined && !banlist.contains(pk) {
out.insert(*pk);
}
}
for (pk, seen_ms) in observed {
if banlist.contains(pk) {
continue;
}
match coalesced.get(pk) {
Some(st) if st.verdict != Verdict::Joined && *seen_ms <= st.at_ms => {}
_ => {
out.insert(*pk);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::super::super::{CommunityId, Epoch};
use super::super::derive::guestbook_group_key;
use super::*;
const NOW: u64 = 1_722_500_000_000;
fn cid() -> CommunityId {
CommunityId([0x33; 32])
}
fn group() -> GroupKey {
guestbook_group_key(&[0x44; 32], &cid(), Epoch(0))
}
fn pk() -> PublicKey {
Keys::generate().public_key()
}
fn always(_: &PublicKey, _: &PublicKey) -> bool {
true
}
fn through(rumor: &UnsignedEvent, author: &Keys) -> GuestbookEvent {
let g = group();
let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap()
}
fn through_err(rumor: &UnsignedEvent, author: &Keys) -> GuestbookError {
let g = group();
let (wrap, _) = seal_guestbook_rumor(rumor, &g, author, Timestamp::from_secs(1_722_400_000)).unwrap();
parse_guestbook_event(&stream::open_wrap(&wrap, &g).unwrap()).unwrap_err()
}
fn join_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Join { member, invited_by: None, at_ms } }
}
fn leave_ev(member: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Leave { member, at_ms } }
}
fn kick_ev(actor: PublicKey, target: PublicKey, at_ms: u64, id: u8) -> GuestbookEvent {
GuestbookEvent { rumor_id: [id; 32], entry: GuestbookEntry::Kick { actor, target, citation: None, at_ms } }
}
fn snap_ev(
refounder: PublicKey,
members: Vec<PublicKey>,
snap: u8,
chunk: (u32, u32),
at_ms: u64,
id: u8,
) -> GuestbookEvent {
GuestbookEvent {
rumor_id: [id; 32],
entry: GuestbookEntry::Snapshot { refounder, members, snapshot_id: [snap; 32], chunk, at_ms },
}
}
#[test]
fn join_and_leave_round_trip_through_the_stream() {
let member = Keys::generate();
let creator = pk().to_hex();
let join = build_join_rumor(member.public_key(), Some((&creator, "Reddit")), 1_722_400_000_128);
let ev = through(&join, &member);
assert_eq!(
ev.entry,
GuestbookEntry::Join {
member: member.public_key(),
invited_by: Some((creator, "Reddit".into())),
at_ms: 1_722_400_000_128,
}
);
let bare = through(&build_join_rumor(member.public_key(), None, 1_000), &member);
assert!(matches!(bare.entry, GuestbookEntry::Join { invited_by: None, .. }));
let leave = through(&build_leave_rumor(member.public_key(), 1_722_400_000_660), &member);
assert_eq!(leave.entry, GuestbookEntry::Leave { member: member.public_key(), at_ms: 1_722_400_000_660 });
}
#[test]
fn kick_round_trips_with_citation_and_the_inner_rumor_id() {
let admin = Keys::generate();
let target = pk();
let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] };
let rumor = build_kick_rumor(admin.public_key(), target, Some(&cite), 1_722_410_000_301);
let g = group();
let (wrap, _) = seal_guestbook_rumor(&rumor, &g, &admin, Timestamp::from_secs(1_722_410_000)).unwrap();
let opened = stream::open_wrap(&wrap, &g).unwrap();
let ev = parse_guestbook_event(&opened).unwrap();
assert_eq!(
ev.entry,
GuestbookEntry::Kick {
actor: admin.public_key(),
target,
citation: Some(cite),
at_ms: 1_722_410_000_301,
}
);
assert_eq!(ev.rumor_id, opened.rumor_id.to_bytes());
assert_ne!(ev.rumor_id, wrap.id.to_bytes());
let bare = through(&build_kick_rumor(admin.public_key(), target, None, 1_000), &admin);
assert!(matches!(bare.entry, GuestbookEntry::Kick { citation: None, .. }));
}
#[test]
fn snapshot_chunks_401_members_1_based_sharing_one_timestamp() {
let refounder = Keys::generate();
let members: Vec<PublicKey> = (0..401).map(|_| pk()).collect();
let rumors = build_snapshot_rumors(refounder.public_key(), &members, [0x5a; 32], 1_722_500_000_000);
assert_eq!(rumors.len(), 2);
let parsed: Vec<GuestbookEvent> = rumors.iter().map(|r| through(r, &refounder)).collect();
let (GuestbookEntry::Snapshot { members: m1, snapshot_id: id1, chunk: c1, at_ms: t1, refounder: r1 },
GuestbookEntry::Snapshot { members: m2, snapshot_id: id2, chunk: c2, at_ms: t2, .. }) =
(&parsed[0].entry, &parsed[1].entry)
else {
panic!("expected two snapshot entries");
};
assert_eq!((m1.len(), m2.len()), (400, 1));
assert_eq!((*c1, *c2), ((1, 2), (2, 2)), "snap indices are 1-based");
assert_eq!(id1, &[0x5a; 32]);
assert_eq!(id1, id2);
assert_eq!(t1, t2, "all chunks share one timestamp");
assert_eq!(*r1, refounder.public_key());
let all: Vec<PublicKey> = m1.iter().chain(m2.iter()).copied().collect();
assert_eq!(all, members, "membership survives the chunking intact");
let empty = build_snapshot_rumors(refounder.public_key(), &[], [0x5b; 32], 1_000);
assert_eq!(empty.len(), 1);
let ev = through(&empty[0], &refounder);
assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, chunk: (1, 1), .. } if members.is_empty()));
}
#[test]
fn plaintext_sealed_guestbook_events_are_rejected() {
let member = Keys::generate();
let g = group();
let rumor = build_join_rumor(member.public_key(), None, 1_000);
let seal = stream::build_seal(&rumor, SealForm::Plaintext, &g, &member).unwrap();
let (wrap, _) = stream::wrap_seal(&seal, &g, stream::KIND_WRAP, Timestamp::from_secs(1)).unwrap();
let opened = stream::open_wrap(&wrap, &g).unwrap();
assert!(matches!(parse_guestbook_event(&opened), Err(GuestbookError::NotEncryptedSealed)));
}
#[test]
fn future_dated_entries_are_dropped_outright() {
let m = pk();
let squat = join_ev(m, NOW + 2 * 3_600_000, 1);
let ok = leave_ev(m, NOW + 59 * 60_000, 2);
let fold = coalesce(&[squat, ok], NOW, None, &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
let edge = join_ev(m, NOW + MAX_FUTURE_MS, 3);
let fold = coalesce(&[edge], NOW, None, &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined);
}
#[test]
fn latest_wins_per_npub_join_leave_rejoin() {
let m = pk();
let evs = [join_ev(m, 1_000, 1), leave_ev(m, 2_000, 2), join_ev(m, 3_000, 3)];
let fold = coalesce(&evs, NOW, None, &always);
let st = fold.get(&m).unwrap();
assert_eq!(st.verdict, Verdict::Joined, "rejoin-after-leave is Joined");
assert_eq!(st.at_ms, 3_000);
let shuffled = [evs[2].clone(), evs[0].clone(), evs[1].clone()];
assert_eq!(coalesce(&shuffled, NOW, None, &always), fold);
let fold = coalesce(&evs[..2], NOW, None, &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
}
#[test]
fn tie_firsthand_beats_snapshot_then_lower_rumor_id() {
let m = pk();
let auth = pk();
let seed = snap_ev(auth, vec![m], 0x01, (1, 1), 5_000, 0x00);
let leave = leave_ev(m, 5_000, 0xff);
for evs in [[seed.clone(), leave.clone()], [leave, seed]] {
let fold = coalesce(&evs, NOW, Some(&auth), &always);
let st = fold.get(&m).unwrap();
assert_eq!(st.verdict, Verdict::Left);
assert_eq!(st.source, Source::Firsthand);
}
let join = join_ev(m, 6_000, 0x01);
let leave = leave_ev(m, 6_000, 0x02);
for evs in [[join.clone(), leave.clone()], [leave.clone(), join.clone()]] {
let fold = coalesce(&evs, NOW, None, &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Joined, "lower id takes the tie");
}
let join = join_ev(m, 7_000, 0x02);
let leave = leave_ev(m, 7_000, 0x01);
let fold = coalesce(&[join, leave], NOW, None, &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
}
#[test]
fn kick_needs_the_callers_authority_verdict() {
let admin = pk();
let m = pk();
let evs = [join_ev(m, 1_000, 1), kick_ev(admin, m, 2_000, 2)];
let denied = coalesce(&evs, NOW, None, &|_, _| false);
assert_eq!(denied.get(&m).unwrap().verdict, Verdict::Joined, "unauthorized kick is ignored");
let granted = coalesce(&evs, NOW, None, &|actor, target| *actor == admin && *target == m);
let st = granted.get(&m).unwrap();
assert_eq!(st.verdict, Verdict::Kicked);
assert_eq!(st.at_ms, 2_000);
}
#[test]
fn snapshots_are_honored_only_from_the_epochs_refounder() {
let refounder = pk();
let impostor = pk();
let m = pk();
let forged = snap_ev(impostor, vec![m], 0x01, (1, 1), 5_000, 1);
assert!(coalesce(&[forged], NOW, Some(&refounder), &always).is_empty());
let real = snap_ev(refounder, vec![m], 0x02, (1, 1), 5_000, 2);
assert!(coalesce(&[real.clone()], NOW, None, &always).is_empty());
let fold = coalesce(&[real], NOW, Some(&refounder), &always);
let st = fold.get(&m).unwrap();
assert_eq!(st.verdict, Verdict::Joined);
assert_eq!(st.source, Source::Snapshot);
assert_eq!(st.at_ms, 5_000);
}
#[test]
fn snapshot_seeds_yield_to_newer_firsthand_but_not_older() {
let refounder = pk();
let m = pk();
let seed = snap_ev(refounder, vec![m], 0x01, (1, 1), 5_000, 1);
let fold = coalesce(&[seed.clone(), leave_ev(m, 6_000, 2)], NOW, Some(&refounder), &always);
assert_eq!(fold.get(&m).unwrap().verdict, Verdict::Left);
let fold = coalesce(&[join_ev(m, 4_000, 3), seed], NOW, Some(&refounder), &always);
let st = fold.get(&m).unwrap();
assert_eq!(st.source, Source::Snapshot);
assert_eq!(st.at_ms, 5_000);
}
#[test]
fn a_torn_snapshot_coalesces_order_independently() {
let refounder = pk();
let (a, b, c) = (pk(), pk(), pk());
let c1 = snap_ev(refounder, vec![a], 0x01, (1, 2), 5_000, 1);
let torn = snap_ev(refounder, vec![b], 0x01, (2, 2), 6_000, 2);
let other = snap_ev(refounder, vec![c], 0x02, (1, 1), 6_000, 3);
let forward = coalesce(&[c1.clone(), torn.clone(), other.clone()], NOW, Some(&refounder), &always);
let reverse = coalesce(&[other, torn, c1], NOW, Some(&refounder), &always);
for m in [&a, &b, &c] {
assert!(forward.contains_key(m) && reverse.contains_key(m), "every authorized chunk seeds its members");
}
assert_eq!(forward, reverse, "a torn snapshot must coalesce identically regardless of order");
}
#[test]
fn complete_memberlist_merges_observation_forward_only_minus_banlist() {
let (joined, left, silent, banned_joined, banned_observed) = (pk(), pk(), pk(), pk(), pk());
let coalesced = coalesce(
&[
join_ev(joined, 1_000, 1),
join_ev(left, 1_000, 2),
leave_ev(left, 5_000, 3),
join_ev(banned_joined, 1_000, 4),
],
NOW,
None,
&always,
);
let banlist: BTreeSet<PublicKey> = [banned_joined, banned_observed].into();
let observed: BTreeMap<PublicKey, u64> = [(left, 4_000), (silent, 100), (banned_observed, 9_000)].into();
let list = complete_memberlist(&coalesced, &observed, &banlist);
assert!(list.contains(&joined));
assert!(list.contains(&silent), "an observed author with no guestbook state is present");
assert!(!list.contains(&left), "activity OLDER than the leave does not resurrect");
assert!(!list.contains(&banned_joined));
assert!(!list.contains(&banned_observed));
let observed: BTreeMap<PublicKey, u64> = [(left, 6_000)].into();
let list = complete_memberlist(&coalesced, &observed, &banlist);
assert!(list.contains(&left));
let observed: BTreeMap<PublicKey, u64> = [(left, 5_000)].into();
assert!(!complete_memberlist(&coalesced, &observed, &banlist).contains(&left));
}
#[test]
fn malformed_verbs_and_foreign_kinds_are_rejected() {
let k = Keys::generate();
for bad in ["JOIN", "Join", " join", "leave ", "", "rejoin"] {
let rumor = stream::build_rumor_ms(kind::JOIN_LEAVE, k.public_key(), bad, vec![], 1_000);
assert!(matches!(through_err(&rumor, &k), GuestbookError::BadVerb), "verb {bad:?} must reject");
}
let msg = stream::build_rumor_ms(kind::MESSAGE, k.public_key(), "hi", vec![], 1_000);
assert!(matches!(through_err(&msg, &k), GuestbookError::NotGuestbook(9)));
}
#[test]
fn kick_target_must_be_a_unique_valid_p_tag() {
let k = Keys::generate();
let dup = stream::build_rumor_ms(
kind::KICK,
k.public_key(),
"",
vec![Tag::public_key(pk()), Tag::public_key(pk())],
1_000,
);
assert!(matches!(through_err(&dup, &k), GuestbookError::DuplicateTag("p")));
let missing = stream::build_rumor_ms(kind::KICK, k.public_key(), "", vec![], 1_000);
assert!(matches!(through_err(&missing, &k), GuestbookError::MissingTag("p")));
let bad = stream::build_rumor_ms(
kind::KICK,
k.public_key(),
"",
vec![Tag::custom(TagKind::Custom("p".into()), ["not-hex".to_string()])],
1_000,
);
assert!(matches!(through_err(&bad, &k), GuestbookError::BadTag("p")));
}
#[test]
fn snapshot_snap_tag_and_content_are_strict() {
let k = Keys::generate();
let id_hex = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
let snap_rumor = |content: &str, id: &str, i: &str, n: &str| {
stream::build_rumor_ms(
kind::SNAPSHOT,
k.public_key(),
content,
vec![Tag::custom(
TagKind::Custom("snap".into()),
[id.to_string(), i.to_string(), n.to_string()],
)],
1_000,
)
};
for (i, n) in [("0", "2"), ("3", "2"), ("abc", "2"), ("1", "x")] {
let r = snap_rumor("[]", &id_hex, i, n);
assert!(matches!(through_err(&r, &k), GuestbookError::BadTag("snap")), "snap {i}/{n} must reject");
}
let bad_id = snap_rumor("[]", "zz", "1", "1");
assert!(matches!(through_err(&bad_id, &k), GuestbookError::BadTag("snap")));
let no_tag = stream::build_rumor_ms(kind::SNAPSHOT, k.public_key(), "[]", vec![], 1_000);
assert!(matches!(through_err(&no_tag, &k), GuestbookError::MissingTag("snap")));
let not_array = snap_rumor("{}", &id_hex, "1", "1");
assert!(matches!(through_err(¬_array, &k), GuestbookError::BadSnapshotContent));
}
#[test]
fn snapshot_bad_member_entries_drop_individually() {
let k = Keys::generate();
let good = pk();
let content = format!(r#"["{}","not-a-pubkey",17]"#, good.to_hex());
let rumor = stream::build_rumor_ms(
kind::SNAPSHOT,
k.public_key(),
&content,
vec![Tag::custom(
TagKind::Custom("snap".into()),
[crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]), "1".to_string(), "1".to_string()],
)],
1_000,
);
let ev = through(&rumor, &k);
assert!(matches!(ev.entry, GuestbookEntry::Snapshot { ref members, .. } if members == &vec![good]));
}
}