use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::PathBuf; use std::time::{Duration, Instant}; use anyhow::Result; use concord::cord02::{ControlFold, ImageRef}; use concord::cord03::{self, ChatMessage, ReplyRef}; use concord::cord04::AuthorityCitation; use concord::cord04::roles::{Permissions, citation_ok}; use concord::cord06::RekeyScope; use concord::derive::{channel_group_key, grant_locator}; use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot}; use concord::{ChannelId, CommunityId, Epoch}; use gpui::{App, AppContext, Context, EventEmitter, Task}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use crate::cache; use crate::history::{self, PageRegistry, Window, WrapPage}; use crate::rekey::{self, Adoptions}; use crate::sync::{self, Snapshot}; /// Wraps one relay returns for one page request. const PAGE_WRAPS: usize = 50; /// Pages a catch-up round walks down a channel's history on its own. const CATCH_UP_PAGES: usize = 20; /// Pages one explicit "load older" fetches from the relays. pub const LOAD_OLDER_PAGES: usize = 6; /// Rows one timeline read returns before the caller asks for more. pub const TIMELINE_PAGE: usize = 100; /// Side events read per row, so a reaction flood cannot displace the rows it decorates. const SIDE_EVENT_FACTOR: usize = 4; /// The shortest gap between two automatic catch-up rounds for one channel. pub const MIN_ROUND_INTERVAL: Duration = Duration::from_secs(30); /// How long a channel may go unsynced before the scheduler repairs it. const STALE_AFTER: Duration = Duration::from_secs(300); /// Which direction a sync round reads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Intent { CatchUp, Older { pages: usize }, } /// What one round saw, for the caller to report and act on. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Progress { /// Wraps the round reached that no key this client holds can open. pub unreadable: usize, /// A relay refused or never answered a page. pub failed: bool, /// How many relays failed the round. pub errors: usize, } /// A channel's timeline, folded from the local cache. #[derive(Debug, Clone, Default)] pub struct Timeline { /// Oldest first, ready for a bottom-aligned list. pub messages: Vec, /// The cache holds rows older than `messages`. pub has_more: bool, } /// One channel's round in flight, coalescing requests that arrive while it runs. #[derive(Default)] struct Round { running: bool, /// The next round to run once this one lands. queued: Option, /// Everyone waiting on the outcome. waiters: Vec>>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionKey { control_pks: BTreeMap, channels: Vec<(ChannelId, Epoch, bool)>, roots: Vec<(u64, [u8; 32])>, relays: Vec, } impl SubscriptionKey { fn of(state: &CommunityState) -> Self { Self { control_pks: state.control_pks.clone(), channels: state .channels .iter() .map(|channel| (channel.id, channel.epoch, channel.private)) .collect(), roots: state .roots() .into_iter() .map(|root| (root.epoch.0, root.key)) .collect(), relays: state.relays.clone(), } } pub(crate) fn relays(&self) -> &[RelayUrl] { &self.relays } } #[derive(Debug, Clone)] pub enum CommunityEvent { Updated(CommunityId), Channel(CommunityId, ChannelId), /// History exists here that no held key can open. Unreadable(CommunityId), /// The last round could not reach the community's relays. Failed(CommunityId), Error(String), } pub struct Community { state: CommunityState, control: ControlFold, members: BTreeSet, active: Option, icon: Option, icon_ref: Option, banner: Option, banner_ref: Option, dirty: bool, refresh_task: Option>>, icon_task: Option>>, banner_task: Option>>, rounds: HashMap, /// The last completed round per channel, for the panel's honest states. progress: HashMap, /// Wraps held per channel that no key we hold can open. unreadable: BTreeMap, /// When each channel's last round started, which paces the automatic ones. last_round: HashMap, pages: PageRegistry, rekey_task: Option>>, rekey_dirty: bool, /// Spawned folds, round bookkeeping and publishes, cancelled on drop tasks: SmallVec<[Task>; 2]>, } impl EventEmitter for Community {} impl Community { pub fn new(state: CommunityState, pages: PageRegistry) -> Self { Self { state, control: ControlFold::default(), members: BTreeSet::new(), active: None, icon: None, icon_ref: None, banner: None, banner_ref: None, dirty: false, refresh_task: None, icon_task: None, banner_task: None, rounds: HashMap::new(), progress: HashMap::new(), unreadable: BTreeMap::new(), last_round: HashMap::new(), pages, rekey_task: None, rekey_dirty: false, tasks: smallvec![], } } pub fn id(&self) -> CommunityId { self.state.id } pub fn state(&self) -> &CommunityState { &self.state } pub fn name(&self) -> String { if let Some(metadata) = &self.control.community { return metadata.name.clone(); } self.state .name .clone() .unwrap_or_else(|| self.state.id.to_hex()) } pub fn control(&self) -> &ControlFold { &self.control } /// The community's icon, once downloaded and decrypted into a cache file. pub fn icon(&self) -> Option { self.icon.clone() } /// The community's banner, once downloaded and decrypted into a cache file. pub fn banner(&self) -> Option { self.banner.clone() } /// The channel the sidebar and panel show, defaulting to the first one. pub fn active_channel(&self) -> Option { self.active .or_else(|| self.state.channels.first().map(|channel| channel.id)) } /// Mark `channel` as the one the sidebar and panel show. pub fn set_active_channel(&mut self, channel: ChannelId, cx: &mut Context) { if self.active == Some(channel) { return; } self.active = Some(channel); cx.emit(CommunityEvent::Channel(self.state.id, channel)); } pub fn members(&self) -> &BTreeSet { &self.members } /// The base epoch a rotation excluded us at: readable history, no writes. pub fn removed_at(&self) -> Option { self.state.removed_at } /// A complete rotation ahead of our epoch predates our join and /// carries no blob for us, so the invite landed us on a superseded epoch. pub fn stranded(&self) -> bool { self.state.stranded } /// Wraps held here that no key we hold can open. pub fn unreadable(&self, channel: &ChannelId) -> usize { self.unreadable.get(channel).copied().unwrap_or(0) } /// The last round's outcome for `channel`, when one has run. pub fn progress(&self, channel: &ChannelId) -> Option { self.progress.get(channel).copied() } /// The epoch a private channel's key is missing for, when we hold none. pub fn missing_key(&self, channel: &ChannelId) -> Option { self.state .channels .iter() .find(|held| held.id == *channel && held.private && held.key.is_none()) .map(|held| held.epoch) } /// The epoch a channel rotation removed us at, when it removed us. pub fn channel_removed_at(&self, channel: &ChannelId) -> Option { self.state.channel_cuts.get(channel).copied() } /// Whether an automatic catch-up for `channel` is worth asking for yet. pub fn due(&self, channel: &ChannelId) -> bool { self.last_round .get(channel) .is_none_or(|at| at.elapsed() >= MIN_ROUND_INTERVAL) } /// Whether `channel` has been synced before and has since gone stale. fn stale(&self, channel: &ChannelId) -> bool { self.last_round .get(channel) .is_some_and(|at| at.elapsed() >= STALE_AFTER) } pub fn channels(&self) -> &[ChannelKeyRef] { &self.state.channels } pub fn subscription_key(&self) -> SubscriptionKey { SubscriptionKey::of(&self.state) } /// A public channel derives its write plane from the community root. fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> { if self.state.removed_at.is_some() || self.state.stranded { return None; } let held = self .state .channels .iter() .find(|held| held.id == *channel)?; if self.state.channel_cut(channel, held.epoch) { return None; } held.current().or_else(|| { (!held.private).then_some((self.state.root_epoch, self.state.community_root)) }) } /// Every secret the client holds for a channel, newest epoch first. fn held_keys(&self, channel: &ChannelId) -> Vec { self.state.held_keys(channel) } pub fn sync_channel( &mut self, channel: &ChannelId, intent: Intent, cx: &mut Context, ) -> Task> { let channel = *channel; let (sender, receiver) = flume::bounded(1); let start = { let round = self.rounds.entry(channel).or_default(); round.waiters.push(sender); let start = !round.running; if !start { round.queued = Some(intent); } start }; if start { self.start_round(channel, intent, cx); } cx.background_spawn(async move { let progress = match receiver.recv_async().await { Ok(progress) => progress, Err(error) => { log::warn!("community: channel round cancelled: {error}"); return Ok(Progress { failed: true, ..Progress::default() }); } }; progress.map_err(anyhow::Error::msg) }) } fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context) { let client = NostrRegistry::global(cx).read(cx).client(); let held = self.held_keys(&channel); let relays = self.state.relays.clone(); let pages = self.pages.clone(); let saved = clamped( self.state .cursors .get(&channel) .copied() .unwrap_or_default(), Timestamp::now(), ); if let Some(round) = self.rounds.get_mut(&channel) { round.running = true; round.queued = None; } self.last_round.insert(channel, Instant::now()); let round = cx.background_spawn(async move { sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await }); let finisher = cx.spawn(async move |this, cx| { let result = round.await; if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) { log::warn!("community: a channel round outlived its community: {error}"); } Ok(()) }); self.tasks.push(finisher); } fn finish_round( &mut self, channel: ChannelId, result: Result, cx: &mut Context, ) { let outcome = match result { Ok((progress, cursor)) => { self.merge_cursor(channel, cursor, cx); Ok(progress) } Err(error) => Err(error.to_string()), }; let Some(round) = self.rounds.get_mut(&channel) else { return; }; round.running = false; let queued = round.queued.take(); let waiters = std::mem::take(&mut round.waiters); for waiter in waiters { if let Err(error) = waiter.try_send(outcome.clone()) { log::warn!("community: a channel round result was not delivered: {error}"); } } if let Some(intent) = queued { self.start_round(channel, intent, cx); } if let Ok(progress) = &outcome { self.progress.insert(channel, *progress); self.record_unreadable(channel, progress.unreadable); if progress.failed && progress.errors > 0 { cx.emit(CommunityEvent::Failed(self.state.id)); } if progress.unreadable > 0 { cx.emit(CommunityEvent::Unreadable(self.state.id)); } } cx.emit(CommunityEvent::Updated(self.state.id)); } /// Remember the wraps no held key could open. fn record_unreadable(&mut self, channel: ChannelId, count: usize) { if count == 0 { return; } let seen = self.unreadable.entry(channel).or_default(); *seen = (*seen).max(count); } fn missing_authority(&self) -> bool { self.state.refounders.is_empty() && self.state.roots().len() > 1 } pub(crate) fn tick(&mut self, cx: &mut Context) { if self.missing_authority() { self.rekey(cx); } let Some(channel) = self.active_channel() else { return; }; if !self.stale(&channel) { return; } self.refresh(cx); let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); let task = cx.spawn(async move |_this, _cx| { if let Err(error) = catch_up.await { log::warn!("community: the scheduler's catch-up failed: {error}"); } Ok(()) }); self.tasks.push(task); } /// Fold a round's cursor findings in, monotonically, and persist them. fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context) { let held = self .state .cursors .get(&channel) .copied() .unwrap_or_default(); let merged = clamped(held.merge(cursor), Timestamp::now()); if merged == held { return; } self.state.cursors.insert(channel, merged); self.persist(cx); } /// Write the local state document out. fn persist(&mut self, cx: &Context) { let client = NostrRegistry::global(cx).read(cx).client(); let state = self.state.clone(); let task = cx.background_spawn(async move { if let Err(error) = cache::save_state(&client, &state).await { log::warn!("community: failed to persist the local state: {error}"); } Ok(()) }); self.tasks.push(task); } /// The channel's timeline, folded from the local cache, oldest first. pub fn timeline( &self, channel: &ChannelId, before_ms: Option, limit: usize, cx: &App, ) -> Task> { let client = NostrRegistry::global(cx).read(cx).client(); let channel = *channel; let owner = self.state.owner; let community_id = self.state.id; let floors = self.state.floors(); let roles = self.control.roles.clone(); cx.background_spawn(async move { let until = before_ms.map(|before_ms| Timestamp::from_secs(before_ms / 1000)); let rows = cache::query_rumors( &client, &channel, until, limit.saturating_add(1), Some(&cord03::ROW_KINDS), ) .await?; let has_more = rows.len() > limit; let sides = cache::query_rumors( &client, &channel, until, limit.saturating_mul(SIDE_EVENT_FACTOR), Some(&cord03::SIDE_KINDS), ) .await?; let mut rumors = Vec::with_capacity(rows.len() + sides.len()); for rumor in rows.iter().take(limit).chain(sides.iter()) { match cord03::parse_rumor(rumor) { Ok(chat) => rumors.push(chat), Err(error) => { log::warn!("community: skipping an unreadable cached rumor: {error}") } } } let mut messages = cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| { citation_ok(&owner, &community_id, actor, citation, &floors) && roles.can_act_on_member( actor, &owner, author, Permissions::MANAGE_MESSAGES, ) }); messages.reverse(); Ok(Timeline { messages, has_more }) }) } /// Seal a message to the channel plane, cache it, then publish it to the relays. pub fn send( &self, channel: &ChannelId, content: &str, reply_to: Option, cx: &App, ) -> Option>> { let (epoch, secret) = self.channel_secret(channel)?; let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); let author = nostr.read(cx).current_user()?; let channel = *channel; let relays = self.state.relays.clone(); let timer = self .control .community .as_ref() .and_then(|metadata| metadata.message_expiration); let content = content.to_owned(); Some(cx.background_spawn(async move { let group = channel_group_key(&secret, &channel, epoch)?; let at_ms = now_ms()?; let rumor = cord03::build_message( author, &channel, epoch, &content, reply_to.as_ref(), at_ms, timer, ); let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?; let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?; cache::cache_rumor(&client, &channel, &opened).await?; sync::connect_relays(&client, &relays).await; sync::publish_wrap(&client, &wrap, &relays).await; Ok(opened.rumor_id) })) } /// Adopt plane material the account's list now carries. /// /// The caller re-folds afterwards; this only seeds the new planes. pub(crate) fn adopt(&mut self, state: CommunityState) { // A fold already in flight would write its pre-adoption state back. self.refresh_task = None; self.dirty = false; self.state = state; } /// Adopt whatever the rekey watch has delivered, then re-page what it moved. pub fn rekey(&mut self, cx: &mut Context) { if self.rekey_task.is_some() { self.rekey_dirty = true; return; } let nostr = NostrRegistry::global(cx); let Some(me) = nostr.read(cx).current_user() else { return; }; let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); let state = self.state.clone(); let roles = self.control.roles.clone(); let task = cx.background_spawn( async move { rekey::adopt(&client, &state, &roles, &signer, me).await }, ); self.rekey_task = Some(cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| this.apply_rekey(result, cx))?; Ok(()) })); } /// Rotate one scope to its next epoch, cutting off `excluded`. pub fn rotate( &mut self, scope: RekeyScope, recipients: &[PublicKey], excluded: &[PublicKey], cx: &mut Context, ) -> Option>> { let nostr = NostrRegistry::global(cx); let me = nostr.read(cx).current_user()?; let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me) { return None; } let rewrite = rekey::Rewrite { scope, recipients: recipients.to_vec(), excluded: excluded.to_vec(), citation: self.citation(&me), }; if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) { return None; } let state = self.state.clone(); let roles = self.control.roles.clone(); Some(cx.spawn(async move |this, cx| { let epoch = rekey::rotate( &client, &state, &roles, &signer, me, &rewrite, Timestamp::now(), ) .await?; let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?; this.update(cx, |this, cx| { if !adoptions.is_empty() { this.merge_adoptions(adoptions, cx); } })?; Ok(epoch) })) } /// The rank this client cites when it acts. fn citation(&self, me: &PublicKey) -> Option { let entity = grant_locator(&self.state.id, &me.to_bytes()); self.state .heads .iter() .find(|head| head.entity == entity) .map(|head| AuthorityCitation { entity: head.entity, version: head.version, hash: head.self_hash, }) } fn apply_rekey(&mut self, result: Result, cx: &mut Context) { self.rekey_task = None; match result { Ok(adoptions) if !adoptions.is_empty() => self.merge_adoptions(adoptions, cx), Ok(_) => {} Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), } if self.rekey_dirty { self.rekey_dirty = false; self.rekey(cx); } } /// Fold an adoption into the held state, persist it, and re-page what moved. fn merge_adoptions(&mut self, adoptions: Adoptions, cx: &mut Context) { let mut touched: Vec = Vec::new(); if let Some(base) = adoptions.base { let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len()); for key in base.stepped { let known = self .state .held_roots .iter() .chain(held.iter()) .any(|root| root.epoch == key.epoch && root.key == key.key); if known { continue; } held.push(HeldRoot { epoch: key.epoch, key: key.key, control_pk: self.state.control_pks.get(&key.epoch.0).copied(), retired_at: key.retired_at, }); } for root in &self.state.held_roots { if held .iter() .any(|kept| kept.epoch == root.epoch && kept.key == root.key) { continue; } held.push(*root); } self.state.held_roots = held; if let Some(control_pk) = base.control_pk { self.state.control_pks.insert(base.epoch.0, control_pk); } // The rotation is authoritative about the new epoch's signing root self.state.control_root = base.control_root; self.state.community_root = base.key; self.state.root_epoch = base.epoch; self.state.removed_at = None; self.state.stranded = false; // Every channel's plane moved with the root. touched.extend(self.state.channels.iter().map(|channel| channel.id)); } for channel in adoptions.channels { let Some(held) = self .state .channels .iter_mut() .find(|held| held.id == channel.channel) else { continue; }; // `stepped` carries the key held before the walk plus every epoch it // passed through, each with the cutoff its superseding rotation set. for key in channel.stepped { if !held.priors.iter().any(|prior| prior.epoch == key.epoch) { held.priors.push(key); } } held.key = Some(channel.key); held.epoch = channel.epoch; held.private = true; touched.push(channel.channel); } for (channel, epoch) in adoptions.cuts { self.state.channels.retain(|held| held.id != channel); self.state.channel_cuts.insert(channel, epoch); self.state.cursors.remove(&channel); } // A rotation that delivered us no key still names the npub that minted the epoch. let learned = adoptions .refounders .into_iter() .filter(|refounder| self.state.refounders.insert(*refounder)) .count(); if let Some(epoch) = adoptions.removed_at { self.state.removed_at = Some(epoch); } if adoptions.stranded { self.state.stranded = true; } for channel in &touched { if let Some(cursor) = self.state.cursors.get_mut(channel) { cursor.exhausted = false; } } self.persist(cx); cx.notify(); cx.emit(CommunityEvent::Updated(self.state.id)); if learned > 0 { self.refresh(cx); } let Some(channel) = self .active .or_else(|| self.state.channels.first().map(|channel| channel.id)) else { return; }; let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); let task = cx.spawn(async move |_this, _cx| { if let Err(error) = catch_up.await { log::warn!("community: the catch-up after a rekey failed: {error}"); } Ok(()) }); self.tasks.push(task); } /// Rebuilds the community from the wraps in the local database. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh_task.is_some() { self.dirty = true; return; } let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let state = self.state.clone(); let folded = cx.background_spawn(async move { sync::fold(&client, &state).await }); self.refresh_task = Some(cx.spawn(async move |this, cx| { let result = folded.await; this.update(cx, |this, cx| this.apply(result, cx))?; Ok(()) })); } fn apply(&mut self, result: Result>, cx: &mut Context) { self.refresh_task = None; match result { Ok(Some(snapshot)) => { let mut state = snapshot.state; state.cursors = std::mem::take(&mut self.state.cursors); self.state = state; self.control = snapshot.control; self.members = snapshot.members; self.load_images(cx); // A fold reads every plane, so its count is the truth for every // channel rather than a sample of the region one round read. // Replacing it is what lets the count fall again once a key is // adopted; a partial round only ever raises it. let reported = !snapshot.unreadable.is_empty(); self.unreadable = snapshot.unreadable; if reported { cx.emit(CommunityEvent::Unreadable(self.state.id)); } cx.emit(CommunityEvent::Updated(self.state.id)); cx.notify(); } Ok(None) => {} Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), } if self.dirty { self.dirty = false; self.refresh(cx); } } /// Resolve the folded icon and banner into local files. fn load_images(&mut self, cx: &mut Context) { let (icon, banner) = match self.control.community.as_ref() { Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()), None => (None, None), }; self.load_icon(icon, cx); self.load_banner(banner, cx); } fn load_icon(&mut self, icon: Option, cx: &mut Context) { if self.icon_ref == icon { return; } self.icon_ref = icon.clone(); self.icon = None; let Some(icon) = icon else { return; }; self.icon_task = Some(cx.spawn(async move |this, cx| { match sync::resolve_image(&icon, cx).await { Ok(path) => { this.update(cx, |this, cx| { this.icon = Some(path); cx.notify(); })?; } Err(error) => log::warn!("community icon: {error}"), } Ok(()) })); } fn load_banner(&mut self, banner: Option, cx: &mut Context) { if self.banner_ref == banner { return; } self.banner_ref = banner.clone(); self.banner = None; let Some(banner) = banner else { return; }; self.banner_task = Some(cx.spawn(async move |this, cx| { match sync::resolve_image(&banner, cx).await { Ok(path) => { this.update(cx, |this, cx| { this.banner = Some(path); cx.notify(); })?; } Err(error) => log::warn!("community banner: {error}"), } Ok(()) })); } } /// One round's progress and the cursor material it earned. type RoundOutcome = (Progress, ChannelCursor); /// A cursor as a filter bound may use it. fn clamped(cursor: ChannelCursor, now: Timestamp) -> ChannelCursor { ChannelCursor { newest: cursor.newest.map(|newest| newest.min(now)), ..cursor } } /// The wall clock in epoch milliseconds: a rumor's `ms` tag carries the part of /// the second the message was written in, and the fold orders rows by it. fn now_ms() -> Result { let elapsed = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|error| anyhow::anyhow!("the system clock is before the Unix epoch: {error}"))?; u64::try_from(elapsed.as_millis()) .map_err(|error| anyhow::anyhow!("the system clock is out of range: {error}")) } /// Read a channel's history from the community's relays, in three passes. async fn sync_round( client: &Client, pages: &PageRegistry, channel: &ChannelId, held: &[HeldKey], relays: &[RelayUrl], saved: ChannelCursor, intent: Intent, ) -> Result { let mut progress = Progress::default(); let mut round = ChannelCursor::default(); // Expired rows drop at fold time otherwise, never from disk. let purged = cache::purge_expired(client, channel, Timestamp::now()).await?; if purged > 0 { log::debug!( "community: purged {purged} expired rumor(s) from {}", channel.to_hex() ); } let newest = match intent { Intent::CatchUp => { let page = history::page( client, pages, channel, held, relays, Window::opening(saved), 1, PAGE_WRAPS, ) .await?; absorb(&mut progress, &page); page } Intent::Older { .. } => WrapPage::default(), }; let bridge = match (intent, newest.oldest, saved.newest) { (Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => { let page = history::page( client, pages, channel, held, relays, Window::between(saved_newest, oldest), CATCH_UP_PAGES, PAGE_WRAPS, ) .await?; absorb(&mut progress, &page); page } // Nothing to bridge, so the newest region is already complete. _ => WrapPage { exhausted: true, ..WrapPage::default() }, }; let resume = match intent { Intent::CatchUp => saved.oldest.or(newest.oldest), Intent::Older { .. } => saved.oldest, }; let budget = match intent { Intent::CatchUp => CATCH_UP_PAGES, Intent::Older { pages } => pages, }; // A channel already swept to the bottom has nothing older to ask for. let older = match (saved.exhausted, resume) { (true, _) => WrapPage { exhausted: true, ..WrapPage::default() }, (false, Some(until)) => { let page = history::page( client, pages, channel, held, relays, Window::older_than(until), budget, PAGE_WRAPS, ) .await?; absorb(&mut progress, &page); page } (false, None) => WrapPage::default(), }; if intent == Intent::CatchUp { let complete = !newest.failed && bridge.exhausted; let top = newest .newest .unwrap_or_default() .max(bridge.newest.unwrap_or_default()); if complete && !top.is_zero() { round.newest = Some(top); } } round.oldest = older.oldest.or(newest.oldest); round.exhausted = older.exhausted; Ok((progress, round)) } fn absorb(progress: &mut Progress, page: &WrapPage) { progress.unreadable += page.unreadable; progress.failed |= page.failed; progress.errors += page.errors; } #[cfg(test)] mod tests { use super::*; /// A stamp ahead of the local clock must never become a cursor. /// /// A durable `newest` past `now` bounds every later REQ below a region that /// has not happened yet, so the channel receives nothing at all — not live, /// not by round — while its older history keeps working, which is exactly /// what a peer with a fast clock (or a hostile stamp) would cause. #[test] fn a_future_stamp_cannot_push_a_cursor_past_now() { let now = Timestamp::now(); let held = ChannelCursor { newest: Some(now - Duration::from_secs(30)), oldest: Some(now - Duration::from_secs(90)), exhausted: false, }; let round = ChannelCursor { newest: Some(now + Duration::from_secs(86_400)), oldest: None, exhausted: true, }; let merged = clamped(held.merge(round), now); assert_eq!(merged.newest, Some(now), "the frontier stops at the clock"); assert_eq!(merged.oldest, Some(now - Duration::from_secs(90))); assert!(merged.exhausted, "the round's other findings still land"); // A cursor already stored past `now` heals instead of staying deaf. let poisoned = ChannelCursor { newest: Some(now + Duration::from_secs(86_400)), ..ChannelCursor::default() }; assert_eq!(clamped(poisoned, now).newest, Some(now)); } /// One public channel under a held root, and nothing else. fn state(channel: ChannelId) -> CommunityState { CommunityState { id: CommunityId::from_bytes([0x42; 32]), name: None, owner: Keys::generate().public_key(), owner_salt: [0x01; 32], community_root: [0x02; 32], root_epoch: Epoch(0), control_root: None, control_pks: BTreeMap::new(), channels: vec![ChannelKeyRef { id: channel, name: "general".to_owned(), private: false, epoch: Epoch(0), key: None, priors: Vec::new(), }], relays: Vec::new(), heads: Vec::new(), banned: BTreeSet::new(), cursors: BTreeMap::new(), held_roots: Vec::new(), channel_cuts: BTreeMap::new(), refounders: BTreeSet::new(), removed_at: None, stranded: false, dissolved: false, added_at_ms: 0, } } fn community(state: CommunityState) -> Community { Community::new(state, PageRegistry::default()) } /// A rotation that excluded us leaves no key to write under: a wrap sealed /// with the retired root would reach nobody who rotated with it. #[test] fn a_removed_member_holds_no_write_key() { let channel = ChannelId::from_bytes([0x9c; 32]); // A public channel writes from the community root while we hold it. assert_eq!( community(state(channel)).channel_secret(&channel), Some((Epoch(0), [0x02; 32])) ); // A base removal closes the community... let mut removed = state(channel); removed.removed_at = Some(Epoch(1)); assert_eq!(community(removed).channel_secret(&channel), None); // ...a strand closes it too, because the rotation moved past our epoch... let mut stranded = state(channel); stranded.stranded = true; assert_eq!(community(stranded).channel_secret(&channel), None); // ...and a channel cut closes the one channel. let mut cut = state(channel); cut.channel_cuts.insert(channel, Epoch(0)); assert_eq!(community(cut).channel_secret(&channel), None); } }