@@ -1,5027 +0,0 @@
1−//! Crux App — the MVU state machine for the skrepka client.
2−//!
3−//! The core owns all business logic: identity, auth, the long-poll loop, the
4−//! outbox, contacts, profiles, and message ingest. The shell provides HTTP,
5−//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
6−//! QR scanning, photo picking, and clipboard.
7−
8−use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9−use std::sync::Arc;
10−use std::time::{Duration, SystemTime, UNIX_EPOCH};
11−
12−use base64::prelude::*;
13−use base64::Engine;
14−
15−use crux_core::{
16− macros::effect,
17− render::{render, RenderOperation},
18− App, Command,
19−};
20−use crux_http::protocol::HttpRequest;
21−use crux_kv::KeyValueOperation;
22−use crux_time::TimeRequest;
23−use facet::Facet;
24−use serde::{Deserialize, Serialize};
25−use url::Url;
26−use zeroize::Zeroizing;
27−
28−use crate::crypto::{Identity, MAX_BLOB_LEN};
29−use crate::model::{
30− hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
31− Settings, StoredMessage, ViewModel, DEFAULT_SERVER_URL, MAX_MESSAGES_PER_PEER,
32−};
33−use crate::phonemic;
34−use crate::protocol::{self, Envelope, Payload};
35−
36−type Http = crux_http::command::Http<Effect, Event>;
37−type KeyValue = crux_kv::KeyValue<Effect, Event>;
38−type Time = crux_time::Time<Effect, Event>;
39−type HttpResult = crux_http::Result<crux_http::Response<Vec<u8>>>;
40−type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
41−
42−/// How far into the future an incoming, sender-chosen `ts` may sit before it is
43−/// clamped to "now" — enough slack for honest clock skew, not enough to let a
44−/// peer park itself at the top of the conversation list.
45−const MAX_FUTURE_SKEW_MS: i64 = 60_000;
46−
47−/// Cap on stored contacts.
48−///
49−/// Any stranger can create one just by sending us a message, so without a cap a
50−/// spammer holding a list of pubkeys can grow `contacts` (and the `contacts` kv
51−/// blob, rewritten in full on every change) without bound. Past the cap we drop
52−/// mail from unknown keys; contacts the user added by hand are never at risk,
53−/// because `AddContact` doesn't go through this path.
54−const MAX_CONTACTS: usize = 500;
55−
56−/// Cap on the total blob bytes we will decrypt out of a single poll page.
57−///
58−/// A hostile relay can answer `/poll` with as many events as it likes. Each one
59−/// costs a hex decode, a scalar mult and an AEAD pass, all before we can tell it
60−/// is junk. `MAX_BLOB_LEN` bounds one blob; this bounds the page. Anything past
61−/// the budget is dropped exactly like an undecryptable blob would be.
62−const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
63−
64−/// Cap on the number of events we will attempt to decrypt out of a single poll
65−/// page, independent of the byte budget. Minimum-size blobs (173 bytes decoded)
66−/// would otherwise permit ~388K events within the 64 MiB byte budget, each
67−/// costing a full scalar mult + SHA-512 + AEAD pass — enough to freeze the app
68−/// for tens of seconds per poll. An honest relay caps at 50 events
69−/// (`maxPollEvents`); this is a hostile-relay bound. Set above `MAX_ACK_IDS`
70−/// (1024) so a legitimate large page from one sender still fully processes.
71−const MAX_POLL_EVENTS: usize = 2000;
72−
73−/// Upper bound on a poll response body we will buffer and parse, in bytes.
74−///
75−/// Before the per-blob byte/count budgets in `ingest_poll` ever run, the client
76−/// reads the entire response body into a `Vec<u8>` and deserializes it. A
77−/// multi-gigabyte response from a hostile relay would OOM the app before
78−/// `ingest_poll`'s defenses are consulted. This cap (generously above what 50
79−/// max-size blobs + JSON envelope could cost) bounds the allocation.
80−const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
81−
82−/// How long a poll may stay in flight before we assume the shell lost it.
83−///
84−/// `polling` gates the whole long-poll loop, and it is only cleared by a
85−/// `PollResult`. If the shell ever drops an HTTP effect (a cancelled task, a
86−/// backgrounded app, a bug), `polling` stays `true` and the client goes quiet
87−/// forever with no visible error. The watchdog re-arms the loop instead.
88−///
89−/// This **must** stay above the shell's own HTTP timeout (70 s, see
90−/// `Effects.swift`), or the watchdog stops being a watchdog: it would fire on
91−/// every poll that is merely slow rather than lost, abandoning a request the
92−/// shell is still going to resolve. The generation check in `PollResult` makes
93−/// that survivable, but each misfire still costs a redundant request to the
94−/// relay, so leave the margin.
95−const POLL_WATCHDOG_MS: u64 = 90_000;
96−
97−/// How long a send may stay in flight before we assume the shell lost it.
98−///
99−/// `flushing` gates the whole send loop, and it is only cleared by a
100−/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
101−/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
102−/// permanently stuck — new messages queue up but never send. The watchdog
103−/// releases the guard so the next `StartFlush` can retry.
104−///
105−/// Kept above the shell's 70 s HTTP timeout for the same reason as
106−/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
107−/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
108−/// one costs a redundant request.
109−const FLUSH_WATCHDOG_MS: u64 = 90_000;
110−
111−/// How long an auth round-trip may stay in flight before we assume the
112−/// shell lost it. Same rationale as `POLL_WATCHDOG_MS`: `authenticating`
113−/// gates the whole auth flow, and without a watchdog a dropped effect
114−/// leaves it stuck forever. Kept above the 70 s shell HTTP timeout.
115−const AUTH_WATCHDOG_MS: u64 = 90_000;
116−
117−/// Messages per `/messages` request.
118−///
119−/// The relay rejects a longer batch outright (`413 batch_too_large`,
120−/// PROTOCOL.md §7), so this is a hard ceiling, not a tuning knob.
121−const MAX_SEND_BATCH: usize = 100;
122−
123−/// ...and a byte ceiling on the same batch, counted in hex characters — i.e. in
124−/// the units the request body is actually built from.
125−///
126−/// The count limit alone is not enough: a hundred profile broadcasts each
127−/// carrying a 64 KiB photo would build a request far past the 16 MiB body cap a
128−/// relay has by default (`--http-max-body-bytes`, PROTOCOL.md §7), and the whole
129−/// batch would come back `413` — which we read as a transient failure and retry,
130−/// forever. Bounding the body here keeps the batch inside what any conforming
131−/// relay accepts. A single item over the budget still goes out alone: it is the
132−/// relay's job to reject it, and its rejection is per-message and permanent.
133−const MAX_SEND_BATCH_BYTES: usize = 4 * 1024 * 1024;
134−
135−/// Ceiling on a `Retry-After` we will honour from a relay.
136−///
137−/// The header is a number the relay chooses and we obey, so without a cap a
138−/// single `Retry-After: 999999999` — from a hostile relay, or a merely broken
139−/// one — parks the outbox for thirty years with nothing on screen to say why.
140−/// Five minutes is longer than any real rate-limit window and still recovers on
141−/// its own.
142−const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
143−
144−// kv keys
145−const K_SETTINGS: &str = "settings";
146−const K_PROFILE: &str = "profile";
147−const K_CONTACTS: &str = "contacts";
148−const K_CURSOR: &str = "cursor";
149−const K_OUTBOX: &str = "outbox";
150−const K_SEEN_IDS: &str = "seen_ids";
151−fn k_messages(peer: &str) -> String {
152− format!("messages:{peer}")
153−}
154−
155−/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
156−/// without bound — a peer who sends thousands of messages accumulates
157−/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
158−/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
159−/// trim-and-replay within the conversation window still hits the set.
160−const MAX_SEEN_IDS_PER_PEER: usize = 2000;
161−
162−// ---------------------------------------------------------------------------
163−// Effect
164−// ---------------------------------------------------------------------------
165−
166−#[effect(facet_typegen)]
167−#[derive(Debug)]
168−pub enum Effect {
169− Render(RenderOperation),
170− Http(HttpRequest),
171− KeyValue(KeyValueOperation),
172− Time(TimeRequest),
173−}
174−
175−// ---------------------------------------------------------------------------
176−// Events
177−// ---------------------------------------------------------------------------
178−
179−#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
180−#[repr(C)]
181−pub enum Event {
182− // ---- lifecycle / persistence (from the shell) ----
183− /// 64-byte Ed25519 secret key loaded (or freshly generated) by the shell.
184− IdentityLoaded(Vec<u8>),
185−
186− // ---- navigation (from the shell) ----
187− ShowConversations,
188− ShowSettings,
189− ShowAddContact,
190− ShowEditProfile,
191− OpenChat(String),
192− Back,
193−
194− // ---- user actions (from the shell) ----
195− ComposeChanged(String),
196− SendText,
197− AddContact { input: String, nickname: String },
198− SetBlocked { peer: String, blocked: bool },
199− /// Forget a peer entirely: the contact, the conversation, and anything still
200− /// queued for them. Blocking only silences a peer; the entry stays in the
201− /// list forever, and there was no way to be rid of it.
202− DeleteContact { peer: String },
203− SaveProfile {
204− display_name: String,
205− bio: String,
206− photo: Option<String>,
207− },
208− SetServerUrl(String),
209− Connect,
210−
211− // ---- internal: kv load results ----
212− #[serde(skip)]
213− #[facet(skip)]
214− LoadedSettings(#[facet(opaque)] KvData),
215− #[serde(skip)]
216− #[facet(skip)]
217− LoadedProfile(#[facet(opaque)] KvData),
218− #[serde(skip)]
219− #[facet(skip)]
220− LoadedContacts(#[facet(opaque)] KvData),
221− #[serde(skip)]
222− #[facet(skip)]
223− LoadedCursor(#[facet(opaque)] KvData),
224− #[serde(skip)]
225− #[facet(skip)]
226− LoadedOutbox(#[facet(opaque)] KvData),
227− #[serde(skip)]
228− #[facet(skip)]
229− LoadedSeenIds(#[facet(opaque)] KvData),
230− #[serde(skip)]
231− #[facet(skip)]
232− LoadedMessages(String, #[facet(opaque)] KvData),
233− #[serde(skip)]
234− #[facet(skip)]
235− Saved(#[facet(opaque)] KvData),
236− /// The cursor write specifically: the next poll is chained off it, so that a
237− /// message is never acked to the server before it is durable locally.
238− #[serde(skip)]
239− #[facet(skip)]
240− SavedCursor(#[facet(opaque)] KvData),
241−
242− // ---- internal: auth / poll / send ----
243− // These drive the core's own loops and are never sent by the shell, so they
244− // are kept off the FFI surface entirely.
245− //
246− // The auth and poll results carry the *generation* of the round-trip that
247− // produced them. An in-flight effect is never cancelled — it can only be
248− // superseded — so without a generation a result belonging to an attempt we
249− // already abandoned lands in the handler for the attempt that replaced it.
250− // See `Model::poll_gen`.
251− #[serde(skip)]
252− #[facet(skip)]
253− Authenticate,
254− #[serde(skip)]
255− #[facet(skip)]
256− Poll,
257− /// Fires `POLL_WATCHDOG_MS` after generation `n`'s poll is issued; a no-op
258− /// unless that exact poll is somehow still in flight.
259− #[serde(skip)]
260− #[facet(skip)]
261− PollWatchdog(u64),
262− #[serde(skip)]
263− #[facet(skip)]
264− StartFlush,
265− /// Fires `FLUSH_WATCHDOG_MS` after generation `n`'s flush is issued; a no-op
266− /// unless that exact send is somehow still in flight — same pattern as
267− /// `PollWatchdog`.
268− #[serde(skip)]
269− #[facet(skip)]
270− FlushWatchdog(u64),
271− /// Fires `AUTH_WATCHDOG_MS` after generation `n`'s auth round-trip is
272− /// issued; a no-op unless that exact auth is somehow still in flight.
273− #[serde(skip)]
274− #[facet(skip)]
275− AuthWatchdog(u64),
276− #[serde(skip)]
277− #[facet(skip)]
278− ChallengeResult(u64, #[facet(opaque)] HttpResult),
279− #[serde(skip)]
280− #[facet(skip)]
281− VerifyResult(u64, #[facet(opaque)] HttpResult),
282− #[serde(skip)]
283− #[facet(skip)]
284− PollResult(u64, #[facet(opaque)] HttpResult),
285− #[serde(skip)]
286− #[facet(skip)]
287− SendResult(u64, #[facet(opaque)] HttpResult),
288−}
289−
290−// ---------------------------------------------------------------------------
291−// Model
292−// ---------------------------------------------------------------------------
293−
294−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
295−pub enum ConnStatus {
296− Offline,
297− Connecting,
298− Online,
299−}
300−
301−#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
302−pub enum Page {
303− /// The shell renders a spinner until `IdentityLoaded` arrives, so there is
304− /// no distinct pre-identity page — conversations is the landing page.
305− #[default]
306− Conversations,
307− Chat,
308− AddContact,
309− Settings,
310− EditProfile,
311−}
312−
313−/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
314−/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
315−const STARTUP_LOADS: u8 = 6;
316−
317−/// The batch a live `/messages` request is carrying: the first `count` items of
318−/// the outbox, all bound for `recipient`.
319−///
320−/// `SendResult` needs all three. The count is what it pops on success and what it
321−/// charges a retry against on a transient failure; the recipient is what tells
322−/// `SetBlocked` and `DeleteContact` whether the items they are about to remove
323−/// are the very ones this send is going to pop; and `bytes` — the size of the
324−/// body we actually built — is what a `413` halves to find a batch this relay
325−/// will accept (see `Model::send_batch_budget`).
326−struct InFlight {
327− recipient: String,
328− count: usize,
329− bytes: usize,
330−}
331−
332−pub struct Model {
333− /// Zeroized on drop: this is the Ed25519 seed, the one secret whose leak
334− /// costs the user their identity.
335− secret_key: Option<Zeroizing<Vec<u8>>>,
336− my_pubkey: String,
337− settings: Settings,
338− profile: OwnProfile,
339− contacts: BTreeMap<String, Contact>,
340− messages: BTreeMap<String, Vec<StoredMessage>>,
341− /// Q18: Per-peer dedup ID set, independent of the trimmed conversation.
342− /// Without this, a replayed blob whose `id` was aged out by `trim_history`
343− /// (past the 1000-message cap) passes the dedup check and re-enters the
344− /// conversation. This set is loaded from and persisted to kv alongside
345− /// messages, so it survives restarts.
346− seen_ids: BTreeMap<String, HashSet<String>>,
347− outbox: VecDeque<OutboxItem>,
348− cursor: i64,
349− token: Option<String>,
350− conn: ConnStatus,
351− poll_retries: u32,
352− /// The send loop's own backoff counter, spent when a `429` arrives with no
353− /// `Retry-After` to obey. Kept apart from `poll_retries` because the poll loop
354− /// resets that one on every page it takes: a client polling happily while the
355− /// relay rate-limits its sends would restart the send backoff at 3 s forever,
356− /// which is not a backoff.
357− send_retries: u32,
358− /// A poll request is in flight. Guards against stacking concurrent long-poll
359− /// loops (every extra loop re-polls itself forever and never terminates).
360− polling: bool,
361− /// Which poll the in-flight `PollResult` is allowed to belong to.
362− ///
363− /// Bumped every time a poll is issued, and every time one is *abandoned* (the
364− /// watchdog gives up on it; a relay switch invalidates it). A `PollResult`
365− /// whose generation no longer matches is dropped on the floor.
366− ///
367− /// A wall-clock "has it been long enough?" test cannot do this job. An HTTP
368− /// effect stays pending across an iOS suspension while `now_ms()` runs on, so
369− /// on resume *every* in-flight poll looks stuck — the watchdog abandons a
370− /// request the shell is still about to resolve, and that resolution then
371− /// clears the guard belonging to its replacement. Two poll loops, each
372− /// re-polling itself forever. The generation is what makes abandoning safe.
373− poll_gen: u64,
374− /// An auth round-trip is in flight. `Connect` is fired from several places
375− /// (startup, a 401, a reconnect timer, a server change); without this guard
376− /// they stack, and each one that lands installs a token and starts its own
377− /// poll loop.
378− authenticating: bool,
379− /// The same idea for the auth round-trip, which spans two legs (challenge →
380− /// verify) and is restarted by `Connect` — which the shell fires on *every*
381− /// return to the foreground (`SkrepkaApp.swift`). The challenge still in
382− /// flight from before the app was backgrounded must not install a token or
383− /// overwrite `conn` on top of the attempt that replaced it.
384− auth_gen: u64,
385− /// A `/messages` request is in flight. One at a time: `SendResult` pops the
386− /// items it sent off the head of the outbox, so two overlapping sends would
387− /// pop each other's messages.
388− flushing: bool,
389− /// Which flush the in-flight `SendResult` / `FlushWatchdog` is allowed to
390− /// belong to. Same pattern as `poll_gen`: bumped every time a flush is
391− /// issued and every time one is abandoned, so a `SendResult` or watchdog
392− /// from a superseded send cannot clear the guard or pop items belonging to
393− /// the replacement.
394− flush_gen: u64,
395− /// What the in-flight send will pop off the head of the outbox when it
396− /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
397− /// while a send is in flight whose items have since been removed from under
398− /// it (`SetBlocked`, `DeleteContact`): there is then nothing left to pop, and
399− /// popping by count anyway would eat whichever messages slid into their place.
400− in_flight: Option<InFlight>,
401− /// What this relay will actually accept as a request body, in hex characters,
402− /// once it has told us. `None` until then — meaning "assume
403− /// `MAX_SEND_BATCH_BYTES`", which is a guess at a cap the protocol never
404− /// publishes.
405− ///
406− /// A relay configured below our guess answers `413`, and a `413` read as a
407− /// transient failure is a batch that is rebuilt identically, rejected
408− /// identically, and retried until every item in it has burned its retry budget
409− /// and been dropped — the outbox drains into the void without a single message
410− /// leaving. So a `413` *measures* the cap instead: halve what we just tried and
411− /// rebuild smaller. It is deliberately not reset on success — the batch that
412− /// worked is evidence about the relay, and the relay's cap does not grow — only
413− /// on a server change, where the evidence no longer applies.
414− send_batch_budget: Option<usize>,
415− /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
416− /// can't land and be persisted against a half-loaded model — which would
417− /// then be clobbered by the loads still in flight.
418− loads_pending: u8,
419− /// A startup kv *read* failed — as opposed to finding the key absent.
420− ///
421− /// The distinction is the whole point of `parse_kv`'s `Result<Option<T>, ()>`:
422− /// an absent key is a fresh install and defaulting is right, but a failed read
423− /// leaves a hole in the model over data that is still on disk, and writing that
424− /// hole back destroys it. The outbox is the sharpest case — `ingest_poll`
425− /// persists it on *every* poll page, so a single failed read followed by one
426− /// empty poll writes `[]` over a queue of real, unsent messages.
427− ///
428− /// So this latches the model as untrustworthy, and every write path checks it:
429− /// no persist, no ingest, no flush. Nothing is lost, because nothing is
430− /// written — the data stays on disk for the next launch, which re-runs the
431− /// loads (`IdentityLoaded` fires on every boot) and clears the flag if they
432− /// succeed. It is deliberately one flag and not a per-key set: a read that
433− /// fails because the device is locked fails for every key, and a model that is
434− /// wrong about its outbox is not one to be trusted about its contacts either.
435− kv_load_failed: bool,
436− /// `now_ms()` before which `flush_next` must not send.
437− ///
438− /// A `429` schedules a `StartFlush` for the far side of the relay's
439− /// `Retry-After`, but that timer is not the only thing that fires one — a poll
440− /// page and a fresh `SendText` both do. Without a pause the wait is decorative:
441− /// the next poll page walks straight through it and hammers the relay that just
442− /// asked us to stop.
443− flush_paused_until: i64,
444− page: Page,
445− active_peer: Option<String>,
446− compose: String,
447− error: Option<String>,
448−}
449−
450−impl Default for Model {
451− fn default() -> Self {
452− Model {
453− secret_key: None,
454− my_pubkey: String::new(),
455− settings: Settings::default(),
456− profile: OwnProfile::default(),
457− contacts: BTreeMap::new(),
458− messages: BTreeMap::new(),
459− seen_ids: BTreeMap::new(),
460− outbox: VecDeque::new(),
461− cursor: 0,
462− token: None,
463− conn: ConnStatus::Offline,
464− poll_retries: 0,
465− send_retries: 0,
466− polling: false,
467− poll_gen: 0,
468− authenticating: false,
469− auth_gen: 0,
470− flushing: false,
471− flush_gen: 0,
472− in_flight: None,
473− send_batch_budget: None,
474− loads_pending: 0,
475− kv_load_failed: false,
476− flush_paused_until: 0,
477− page: Page::default(),
478− active_peer: None,
479− compose: String::new(),
480− error: None,
481− }
482− }
483−}
484−
485−impl Model {
486− fn identity(&self) -> Option<Identity> {
487− self.secret_key
488− .as_ref()
489− .and_then(|sk| Identity::from_secret_bytes(sk).ok())
490− }
491−
492− /// Tick off one startup load. Returns `true` exactly once — when the last of
493− /// the five lands — so `Connect` is emitted a single time.
494− fn startup_load_done(&mut self) -> bool {
495− if self.loads_pending == 0 {
496− return false;
497− }
498− self.loads_pending -= 1;
499− self.loads_pending == 0
500− }
501−
502− /// Open a new poll generation and return it. Any `PollResult` still in flight
503− /// under the old generation is now dead to us.
504− fn next_poll_gen(&mut self) -> u64 {
505− self.poll_gen = self.poll_gen.wrapping_add(1);
506− self.poll_gen
507− }
508−
509− /// Abandon the in-flight poll, if any: release the loop guard and retire the
510− /// generation so a late `PollResult` cannot clear the *replacement* poll's
511− /// guard or fold a stale page (and a stale cursor) into the model.
512− fn abandon_poll(&mut self) {
513− self.polling = false;
514− self.poll_gen = self.poll_gen.wrapping_add(1);
515− }
516−
517− /// Open a new auth generation and return it.
518− fn next_auth_gen(&mut self) -> u64 {
519− self.auth_gen = self.auth_gen.wrapping_add(1);
520− self.auth_gen
521− }
522−
523− /// Open a new flush generation and return it. Same pattern as
524− /// `next_poll_gen`: a `SendResult` or `FlushWatchdog` whose generation no
525− /// longer matches is dropped, so a late result from a superseded send
526− /// cannot clear the guard or pop items belonging to the replacement.
527− fn next_flush_gen(&mut self) -> u64 {
528− self.flush_gen = self.flush_gen.wrapping_add(1);
529− self.flush_gen
530− }
531−
532− /// Abandon the in-flight flush, if any: release the guard and retire the
533− /// generation so a late `SendResult` cannot clear the *replacement* flush's
534− /// guard or pop its items.
535− fn abandon_flush(&mut self) {
536− self.flushing = false;
537− self.in_flight = None;
538− self.flush_gen = self.flush_gen.wrapping_add(1);
539− }
540−
541− /// Abandon the in-flight auth round-trip (either leg).
542− fn abandon_auth(&mut self) {
543− self.authenticating = false;
544− self.auth_gen = self.auth_gen.wrapping_add(1);
545− }
546−
547− /// Every item bound for `peer` is about to be removed from the outbox. If the
548− /// send in flight is the one carrying them, forget what it was going to pop:
549− /// those items will not be there when it lands, and popping by count anyway
550− /// would take whatever slid into their place — another peer's mail.
551− ///
552− /// The `flushing` guard stays up. Nothing can cancel the request; it is still
553− /// out there, and a second send started underneath it would have its own items
554− /// popped by the first one's result.
555− fn detach_in_flight(&mut self, peer: &str) {
556− if self.in_flight.as_ref().is_some_and(|f| f.recipient == peer) {
557− self.in_flight = None;
558− }
559− }
560−
561− /// How many items at the head of the outbox are already on the wire, and so
562− /// must not be rewritten in place (their ciphertext is built and gone).
563− fn in_flight_count(&self) -> usize {
564− self.in_flight.as_ref().map_or(0, |f| f.count)
565− }
566−}
567−
568−// ---------------------------------------------------------------------------
569−// Helpers (pure)
570−// ---------------------------------------------------------------------------
571−
572−/// Wall-clock milliseconds.
573−///
574−/// This reads the system clock from inside `update()`, which is supposed to be a
575−/// pure function of `(Event, Model)`. It isn't, and that has real costs: the
576−/// state machine can't be replayed deterministically, and tests can't pin "now".
577−/// The clean fix is to take the time as a `crux_time` effect and feed it back in
578−/// as an event — but every timestamped path (`SendText`, `SaveProfile`,
579−/// `ingest_poll`) would have to become a two-step round-trip through the shell.
580−/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
581−/// the impurity is documented rather than hidden.
582−// TODO: replace with crux_time effect for deterministic timestamps
583−fn now_ms() -> i64 {
584− SystemTime::now()
585− .duration_since(UNIX_EPOCH)
586− .map(|d| d.as_millis() as i64)
587− .unwrap_or(1)
588−}
589−
590−/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
591−///
592−/// This feeds the auth signature, which is bound to the host (PROTOCOL.md §6),
593−/// so it must agree with what the relay itself computes. Splitting on `:` by hand
594−/// mangles an IPv6 literal (`http://[::1]:8080` → `[`), producing a signature the
595−/// server rejects; `Url` gets the bracket rules right.
596−fn server_host(url: &str) -> String {
597− Url::parse(url)
598− .ok()
599− .and_then(|u| u.host_str().map(str::to_string))
600− .map(|h| h.trim_end_matches('.').to_lowercase())
601− .unwrap_or_default()
602−}
603−
604−/// Validate and canonicalize a user-entered relay URL.
605−///
606−/// The shell hands us raw text-field contents and `crux_http` unwraps the parse,
607−/// so anything the real parser rejects must be rejected *here* or it panics the
608−/// core. That means validating with the same parser rather than a lookalike: the
609−/// hand-rolled check this replaces accepted `http://[bad` and `http://ho^st`,
610−/// both of which `Url::parse` refuses.
611−///
612−/// A query or fragment is rejected too. Every request appends a path
613−/// (`{url}/poll`), so `http://x?y` would yield `http://x?y/poll` — the path
614−/// swallowed into the query string, pointing at nothing.
615−///
616−/// Returns the normalized URL (no trailing slash) or `None`.
617−fn normalize_server_url(input: &str) -> Option<String> {
618− let url = Url::parse(input.trim()).ok()?;
619− if !matches!(url.scheme(), "http" | "https") {
620− return None;
621− }
622− if url.host_str().is_none_or(str::is_empty) {
623− return None;
624− }
625− if url.query().is_some() || url.fragment().is_some() {
626− return None;
627− }
628− Some(url.as_str().trim_end_matches('/').to_string())
629−}
630−
631−fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
632− serde_json::to_vec(value).unwrap_or_default()
633−}
634−
635−// ---------------------------------------------------------------------------
636−// Wire request/response shapes
637−// ---------------------------------------------------------------------------
638−
639−#[derive(Serialize)]
640−struct ChallengeReq<'a> {
641− pubkey: &'a str,
642−}
643−#[derive(Deserialize)]
644−struct ChallengeResp {
645− challenge: String,
646−}
647−#[derive(Serialize)]
648−struct VerifyReq<'a> {
649− pubkey: &'a str,
650− challenge: &'a str,
651− signature: &'a str,
652− #[serde(rename = "revokeOthers")]
653− revoke_others: bool,
654−}
655−#[derive(Deserialize)]
656−struct VerifyResp {
657− token: String,
658−}
659−#[derive(Serialize)]
660−struct PollReq {
661− cursor: i64,
662−}
663−#[derive(Deserialize, Default)]
664−struct PollResp {
665− #[serde(default)]
666− events: Vec<PollEvent>,
667− #[serde(default)]
668− cursor: i64,
669−}
670−#[derive(Deserialize)]
671−struct PollEvent {
672− #[serde(rename = "encryptedBlob")]
673− encrypted_blob: String,
674−}
675−
676−/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
677−/// across all `delivery.ack` payloads from one sender in a single poll page,
678−/// so the staleness check and watermark update run once per sender after the
679−/// event loop — immune to relay page ordering.
680−#[derive(Default)]
681−struct PendingAck {
682− ts: i64,
683− ids: HashSet<String>,
684−}
685−#[derive(Serialize)]
686−struct SendBatch {
687− messages: Vec<Envelope>,
688−}
689−
690−// ---------------------------------------------------------------------------
691−// App
692−// ---------------------------------------------------------------------------
693−
694−#[derive(Default)]
695−pub struct Skrepka;
696−
697−impl App for Skrepka {
698− type Event = Event;
699− type Model = Model;
700− type ViewModel = ViewModel;
701− type Effect = Effect;
702−
703− #[allow(clippy::too_many_lines)]
704− fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
705− match event {
706− // ---------------- lifecycle ----------------
707− Event::IdentityLoaded(sk) => {
708− let Ok(id) = Identity::from_secret_bytes(&sk) else {
709− model.error = Some("invalid identity key".into());
710− return render();
711− };
712− model.secret_key = Some(Zeroizing::new(sk));
713− model.my_pubkey = id.public_key_hex();
714− model.page = Page::Conversations;
715− // Fan out the startup loads. `Connect` waits until the last of
716− // them lands (see `startup_load_done`).
717− model.loads_pending = STARTUP_LOADS;
718− // A fresh round of loads: whatever a previous one failed to read,
719− // this one gets to answer for. Clearing it here is what lets a
720− // relaunch recover from a locked-device read failure.
721− model.kv_load_failed = false;
722− Command::all([
723− KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
724− KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
725− KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
726− KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
727− KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
728− KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
729− ])
730− .and(render())
731− }
732−
733− Event::LoadedSettings(res) => {
734− match parse_kv::<Settings>(res) {
735− Ok(Some(s)) => {
736− // Re-validate on the way in. The URL was normalized when the
737− // user typed it, but the blob on disk is not trustworthy: an
738− // older build wrote URLs this parser rejects, and a corrupted
739− // or hand-edited file would reach `Http::post`, which unwraps
740− // the parse. That is a panic on every launch — a boot loop
741− // with no way out from inside the app.
742− model.settings.server_url = normalize_server_url(&s.server_url)
743− .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
744− }
745− Ok(None) => {}
746− Err(()) => {
747− model.kv_load_failed = true;
748− model.error = Some("could not read settings from storage".into());
749− }
750− }
751− self.startup_done(model).and(render())
752− }
753− Event::LoadedProfile(res) => {
754− match parse_kv::<OwnProfile>(res) {
755− Ok(Some(p)) => model.profile = p,
756− Ok(None) => {}
757− Err(()) => {
758− model.kv_load_failed = true;
759− model.error = Some("could not read profile from storage".into());
760− }
761− }
762− self.startup_done(model).and(render())
763− }
764− Event::LoadedContacts(res) => {
765− let mut cmds = vec![self.startup_done(model)];
766− match parse_kv::<Vec<Contact>>(res) {
767− Ok(Some(list)) => {
768− model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
769− // Lazily load each conversation's messages.
770− cmds.extend(model.contacts.keys().map(|peer| {
771− let peer = peer.clone();
772− KeyValue::get(k_messages(&peer))
773− .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
774− }));
775− }
776− Ok(None) => {}
777− Err(()) => {
778− model.kv_load_failed = true;
779− model.error = Some("could not read contacts from storage".into());
780− }
781− }
782− Command::all(cmds).and(render())
783− }
784− Event::LoadedCursor(res) => {
785− match parse_kv::<i64>(res) {
786− Ok(Some(c)) => model.cursor = c,
787− Ok(None) => {}
788− Err(()) => {
789− model.kv_load_failed = true;
790− model.error = Some("could not read cursor from storage".into());
791− }
792− }
793− self.startup_done(model).and(render())
794− }
795− Event::LoadedOutbox(res) => {
796− match parse_kv::<Vec<OutboxItem>>(res) {
797− Ok(Some(list)) => model.outbox = list.into(),
798− Ok(None) => {}
799− Err(()) => {
800− model.kv_load_failed = true;
801− model.error = Some("could not read outbox from storage".into());
802− }
803− }
804− self.startup_done(model).and(render())
805− }
806− Event::LoadedSeenIds(res) => {
807− match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808− Ok(Some(map)) => model.seen_ids = map,
809− Ok(None) => {}
810− Err(()) => {
811− // Non-fatal: dedup falls back to the conversation-derived
812− // set, which is weaker but functional.
813− model.seen_ids = BTreeMap::new();
814− }
815− }
816− self.startup_done(model).and(render())
817− }
818− Event::LoadedMessages(peer, res) => {
819− match parse_kv::<Vec<StoredMessage>>(res) {
820− Ok(Some(list)) => {
821− // D5: Add on-disk message IDs to seen_ids so future
822− // replays don't pass dedup. Without this, a seen_ids
823− // kv load failure leaves the set empty, and on-disk
824− // messages aren't protected against replay.
825− if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826− id_set.extend(list.iter().map(|m| m.id.clone()));
827− }
828− // Merge: a poll that beat this lazy load may have already
829− // inserted messages for this peer. Overwriting with the
830− // on-disk list (which the GET read before the poll wrote)
831− // would silently destroy the freshly-ingested messages.
832− // Union by id, keeping both the on-disk history and any
833− // in-memory additions.
834− match model.messages.get_mut(&peer) {
835− Some(existing) => {
836− let existing_ids: HashSet<&String> =
837− existing.iter().map(|m| &m.id).collect();
838− let new_msgs: Vec<StoredMessage> = list
839− .into_iter()
840− .filter(|m| !existing_ids.contains(&m.id))
841− .collect();
842− existing.extend(new_msgs);
843− // Re-sort by ts to maintain the invariant.
844− existing.sort_by_key(|m| m.ts);
845− trim_history(existing);
846− }
847− None => {
848− model.messages.insert(peer, list);
849− }
850− }
851− }
852− Ok(None) => {}
853− Err(()) => {
854− // Leaving the conversation absent is not enough on its own.
855− // An absent conversation is an *empty* one to everything
856− // downstream, so the next message from this peer would be
857− // appended to nothing and `persist_messages` would write
858− // that one message over the whole history still on disk.
859− // Latch the model as untrustworthy instead — same reasoning
860− // as the outbox, same fix.
861− model.kv_load_failed = true;
862− model.error = Some("could not read messages from storage".into());
863− }
864− }
865− render()
866− }
867− Event::Saved(res) => {
868− // A dropped write silently loses data (a message, a contact, the
869− // cursor), so surface it rather than swallowing the result.
870− if let Err(e) = res {
871− model.error = Some(format!("could not save data: {e}"));
872− return render();
873− }
874− Command::done()
875− }
876− Event::SavedCursor(res) => {
877− // The cursor write is what tells the relay it may delete the mail
878− // we just took (it acks up to `cursor` on the next poll). Only
879− // re-poll once it has actually landed: if we polled in parallel
880− // and the write failed, the messages would be gone from the
881− // server and absent from disk.
882− if let Err(e) = res {
883− model.error = Some(format!("could not save data: {e}"));
884− return self.backoff_poll(model);
885− }
886− Command::event(Event::Poll)
887− }
888−
889− // ---------------- navigation ----------------
890− // `error` is about the thing the user was just doing. Leaving it set
891− // across a page change strands a stale message ("invalid public key")
892− // on an unrelated screen, so every navigation clears it.
893− Event::ShowConversations => {
894− model.page = Page::Conversations;
895− model.active_peer = None;
896− model.error = None;
897− render()
898− }
899− Event::ShowSettings => {
900− model.page = Page::Settings;
901− model.error = None;
902− render()
903− }
904− Event::ShowAddContact => {
905− model.page = Page::AddContact;
906− model.error = None;
907− render()
908− }
909− Event::ShowEditProfile => {
910− model.page = Page::EditProfile;
911− model.error = None;
912− render()
913− }
914− Event::OpenChat(peer) => {
915− model.active_peer = Some(peer);
916− model.page = Page::Chat;
917− model.error = None;
918− render()
919− }
920− Event::Back => {
921− model.page = Page::Conversations;
922− model.active_peer = None;
923− model.error = None;
924− render()
925− }
926−
927− // ---------------- user actions ----------------
928− Event::ComposeChanged(s) => {
929− model.compose = s;
930− render()
931− }
932− Event::SetServerUrl(url) => {
933− let Some(url) = normalize_server_url(&url) else {
934− model.error = Some("server URL must be an http(s) address".into());
935− return render();
936− };
937− model.error = None;
938− model.settings.server_url = url;
939− model.token = None;
940− model.conn = ConnStatus::Offline;
941− // Whatever we learned about the old relay's body cap was about *it*.
942− // A new relay is entitled to a bigger one, and carrying a shrunken
943− // budget over would cap our batches against a limit that no longer
944− // exists — with no `413` left to correct it, since the new relay has
945− // no reason to send one.
946− model.send_batch_budget = None;
947− // The cursor is *per relay*: it is that server's own sequence
948− // number, and the next poll acks everything up to it. Carrying
949− // relay A's cursor (roughly `now_ms()`) over to relay B tells B
950− // to delete every message queued for us before it hands any of
951− // them over. Start B from the beginning.
952− model.cursor = 0;
953− // ...and retire the poll that is still in flight *to relay A*, or
954− // resetting the cursor achieves nothing: A's answer is already on
955− // its way, it carries A's cursor, and `ingest_poll` would write it
956− // straight back over the zero we just set. The next poll — to B —
957− // would then ack a sequence number B never issued, and B would
958− // drop every message waiting for us.
959− model.abandon_poll();
960− // ...and retire the send that is still in flight *to relay A*,
961− // for the same reason: its `SendResult` carries A's acceptance,
962− // and popping items here would eat whatever the new relay's
963− // send is about to carry. The generation check in `SendResult`
964− // is what makes the late result a no-op.
965− model.abandon_flush();
966− // The old relay's rate limit was the old relay's opinion. A new one is
967− // entitled to take our sends immediately, and carrying the pause over
968− // would park the outbox against a limit that no longer applies.
969− model.flush_paused_until = 0;
970− model.send_retries = 0;
971− // D2: Reset poll retries too — the new relay's poll failures
972− // should start fresh, not inherit the old relay's backoff.
973− model.poll_retries = 0;
974− let writes = if self.refuse_write(model) {
975− Command::done()
976− } else {
977− Command::all([
978− KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
979− .then_send(Event::Saved),
980− KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
981− ])
982− };
983− writes.and(Command::event(Event::Connect)).and(render())
984− }
985− Event::Connect => {
986− // Connect restarts the auth flow, so whatever challenge/verify was
987− // in flight is now stale and its guard must come off. Retiring the
988− // generation as we do it is what makes that safe: the shell fires
989− // Connect on every return to the foreground, so the round-trip we
990− // are dropping here is usually one that is still very much alive
991− // and about to resolve. Clearing the guard alone would let it land
992− // and install its token on top of the attempt replacing it.
993− model.abandon_auth();
994− // The shell fires `Connect` on every return to the foreground. A model
995− // that failed a startup read must not take that invitation: connecting
996− // leads to polling, and polling into a hole acks mail to the relay that
997− // we cannot store. Staying offline is what keeps the disk intact until
998− // a relaunch re-runs the loads.
999− if model.kv_load_failed {
1000− return render();
1001− }
1002− if model.secret_key.is_some() && model.conn != ConnStatus::Online {
1003− Command::event(Event::Authenticate)
1004− } else if model.secret_key.is_some()
1005− && model.conn == ConnStatus::Online
1006− && !model.polling
1007− {
1008− // The poll loop can die without `conn` going offline: after
1009− // `PollResult` clears `polling`, the re-poll is chained off
1010− // `SavedCursor`. If the shell drops that effect (backgrounded
1011− // app, bug), `polling` stays `false` with no pending re-poll
1012− // and the watchdog sees `!polling` and does nothing. The
1013− // status shows "online" but no polling ever happens. Restart
1014− // the loop here — `Connect` is fired on every foreground return.
1015− Command::event(Event::Poll)
1016− } else {
1017− render()
1018− }
1019− }
1020− Event::AddContact { input, nickname } => {
1021− // Clear a stale error from a previous failed add.
1022− model.error = None;
1023− // A nickname is stored in the contacts kv blob, which is
1024− // rewritten in full on every change. An unbounded nickname
1025− // bloats the blob and every subsequent write.
1026− if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
1027− model.error = Some("nickname too long".into());
1028− return render();
1029− }
1030− match phonemic::try_parse_pubkey(&input) {
1031− Some(hex) if hex != model.my_pubkey => {
1032− model
1033− .contacts
1034− .entry(hex.clone())
1035− .or_insert_with(|| Contact::new(hex.clone(), nickname.clone(), now_ms()));
1036− if !nickname.is_empty() {
1037− if let Some(c) = model.contacts.get_mut(&hex) {
1038− c.nickname = nickname;
1039− }
1040− }
1041− model.error = None;
1042− model.page = Page::Conversations;
1043− self.persist_contacts(model).and(render())
1044− }
1045− Some(_) => {
1046− model.error = Some("that's your own key".into());
1047− render()
1048− }
1049− None => {
1050− model.error = Some("invalid public key".into());
1051− render()
1052− }
1053− }
1054− }
1055− Event::SetBlocked { peer, blocked } => {
1056− if let Some(c) = model.contacts.get_mut(&peer) {
1057− c.blocked = blocked;
1058− }
1059− // Clear a stale error (e.g. "cannot send to a blocked contact")
1060− // when the user unblocks — the block that caused it is now lifted.
1061− model.error = None;
1062− if blocked {
1063− // Gating *ingest* on the block only stops the acks we have not
1064− // queued yet. Anything already sitting in the outbox for this
1065− // peer — an ack for the very message that made the user block
1066− // them, a profile from the last broadcast — still flushes, and
1067− // an ack is precisely the "we are online and reading you"
1068− // signal the block is supposed to cut off. Drop it all: once
1069− // blocked, nothing more goes to this peer.
1070− model.outbox.retain(|item| item.recipient != peer);
1071− model.detach_in_flight(&peer);
1072− return Command::all([self.persist_contacts(model), self.persist_outbox(model)])
1073− .and(render());
1074− }
1075− self.persist_contacts(model).and(render())
1076− }
1077− Event::DeleteContact { peer } => {
1078− model.contacts.remove(&peer);
1079− model.messages.remove(&peer);
1080− model.seen_ids.remove(&peer);
1081− model.outbox.retain(|item| item.recipient != peer);
1082− // The send in flight may be carrying the very items we just
1083− // dropped; if so its result must pop nothing.
1084− model.detach_in_flight(&peer);
1085− if model.active_peer.as_deref() == Some(peer.as_str()) {
1086− model.active_peer = None;
1087− model.page = Page::Conversations;
1088− }
1089− model.error = None;
1090− Command::all([
1091− self.persist_contacts(model),
1092− // The conversation is gone from the model, but `persist_messages`
1093− // would leave a `messages:<peer>` blob behind holding every
1094− // message the user just asked us to forget. Delete the key.
1095− KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
1096− self.persist_outbox(model),
1097− // Q18: persist the seen_ids map without the removed peer.
1098− self.persist_seen_ids(model),
1099− ])
1100− .and(render())
1101− }
1102− Event::SaveProfile {
1103− display_name,
1104− bio,
1105− photo,
1106− } => {
1107− // Clear a stale error from a previous failed save.
1108− model.error = None;
1109− // The recipient's `parse_payload` enforces these caps and
1110− // silently drops oversized profiles. Without these checks the
1111− // sender's local profile would be saved, broadcast to every
1112− // contact, and silently dropped by every recipient — a silent
1113− // divergence between what the sender sees and what contacts see.
1114− if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
1115− model.error = Some("display name too long".into());
1116− return render();
1117− }
1118− if bio.chars().count() > protocol::MAX_BIO_LEN {
1119− model.error = Some("bio too long".into());
1120− return render();
1121− }
1122− if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
1123− model.error = Some("photo too large".into());
1124− return render();
1125− }
1126− // S6: Validate the photo is valid base64 before broadcasting —
1127− // the recipient's parse_payload rejects invalid base64, so an
1128− // invalid photo would be silently dropped by every recipient.
1129− if photo.as_ref().is_some_and(|p| BASE64_STANDARD.decode(p).is_err()) {
1130− model.error = Some("photo is not valid base64".into());
1131− return render();
1132− }
1133− model.profile = OwnProfile {
1134− display_name,
1135− bio,
1136− photo,
1137− };
1138− model.page = Page::Conversations;
1139− let ts = now_ms();
1140− let payload = Payload::Profile {
1141− display_name: Some(model.profile.display_name.clone()),
1142− bio: Some(model.profile.bio.clone()),
1143− photo: model.profile.photo.clone(),
1144− };
1145− // Broadcast the new profile to every contact we haven't blocked.
1146− // Blocking is meant to cut the peer off in both directions; a
1147− // profile broadcast would keep feeding them our display name,
1148− // avatar, and a liveness signal.
1149− //
1150− // One `Arc<String>` for the whole fan-out: the payload carries a
1151− // base64 photo, and a copy per contact would be megabytes in memory
1152− // for a list of any size. (The `outbox` kv blob still serializes the
1153− // string once per item — serde has no idea the `Arc`s are shared —
1154− // but the supersede-in-place below caps that at one pending profile
1155− // per contact, which is the bound that actually matters.)
1156− let recipients: Vec<String> = model
1157− .contacts
1158− .values()
1159− .filter(|c| !c.blocked)
1160− .map(|c| c.pubkey.clone())
1161− .collect();
1162− let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
1163− // Items already on the wire cannot be rewritten: their ciphertext is
1164− // built and gone, and `SendResult` will pop them on success — so a
1165− // payload swapped into one now would be dropped having never been
1166− // sent. Supersede only what is still behind them.
1167− let live = model.in_flight_count();
1168− for peer in recipients {
1169− // A profile is state, not an event: only the newest one means
1170− // anything. Overwrite the pending one for this peer instead of
1171− // queueing a second — five quick edits to a profile with a photo
1172− // would otherwise be five payloads per contact, each of which we
1173− // then encrypt and send.
1174− match model
1175− .outbox
1176− .iter_mut()
1177− .skip(live)
1178− .find(|i| i.is_profile() && i.recipient == peer)
1179− {
1180− Some(pending) => pending.envelope_json = Arc::clone(&payload_json),
1181− None => model
1182− .outbox
1183− .push_back(OutboxItem::profile(peer, Arc::clone(&payload_json))),
1184− }
1185− }
1186− Command::all([
1187− self.persist_profile(model),
1188− self.persist_outbox(model),
1189− ])
1190− .and(Command::event(Event::StartFlush))
1191− .and(render())
1192− }
1193− Event::SendText => self.send_text(model),
1194−
1195− // ---------------- auth ----------------
1196− Event::Authenticate => {
1197− // Exactly one auth round-trip at a time. `Authenticate` is reached
1198− // from startup, a 401 on poll, a 401 on send, a reconnect timer
1199− // and a server change — all of which can fire at once after a
1200− // network blip. Each one that got through would hit
1201− // `/auth/challenge`, and each token that came back would install
1202− // itself and kick off another poll loop.
1203− if model.authenticating {
1204− return Command::done();
1205− }
1206− let Some(id) = model.identity() else {
1207− return render();
1208− };
1209− model.conn = ConnStatus::Connecting;
1210− model.error = None;
1211− let url = format!("{}/auth/challenge", model.settings.server_url);
1212− let body = ChallengeReq {
1213− pubkey: &id.public_key_hex(),
1214− };
1215− match Http::post(url).body_json(&body) {
1216− Ok(req) => {
1217− model.authenticating = true;
1218− let gen = model.next_auth_gen();
1219− req.build()
1220− .then_send(move |r| Event::ChallengeResult(gen, r))
1221− .and(
1222− Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1223− .0
1224− .then_send(move |_| Event::AuthWatchdog(gen)),
1225− )
1226− .and(render())
1227− }
1228− Err(_) => {
1229− model.conn = ConnStatus::Offline;
1230− model.error = Some("could not build auth request".into());
1231− self.schedule_reconnect(model)
1232− }
1233− }
1234− }
1235− Event::ChallengeResult(gen, res) => {
1236− // A challenge from an attempt we already gave up on (a relay
1237− // switch, a foreground `Connect`). Its `authenticating` is not
1238− // ours to clear and its challenge is not ours to sign.
1239− if gen != model.auth_gen {
1240− return Command::done();
1241− }
1242− // Cleared here and re-set only if the verify leg actually goes
1243− // out — every exit from this arm must leave the guard consistent.
1244− model.authenticating = false;
1245− let Ok(mut resp) = res else {
1246− model.conn = ConnStatus::Offline;
1247− return self.schedule_reconnect(model);
1248− };
1249− let bytes = resp.take_body().unwrap_or_default();
1250− let Some(id) = model.identity() else {
1251− return render();
1252− };
1253− let Ok(c) = serde_json::from_slice::<ChallengeResp>(&bytes) else {
1254− model.conn = ConnStatus::Offline;
1255− return self.schedule_reconnect(model);
1256− };
1257− let host = server_host(&model.settings.server_url);
1258− let Ok(signature) = id.sign_challenge(&host, &c.challenge) else {
1259− // An over-long challenge: a relay trying to get us to sign
1260− // something of its choosing. Back off rather than comply.
1261− model.conn = ConnStatus::Offline;
1262− model.error = Some("relay sent a malformed auth challenge".into());
1263− return self.schedule_reconnect(model);
1264− };
1265− let url = format!("{}/auth/verify", model.settings.server_url);
1266− let body = VerifyReq {
1267− pubkey: &id.public_key_hex(),
1268− challenge: &c.challenge,
1269− signature: &signature,
1270− revoke_others: false,
1271− };
1272− match Http::post(url).body_json(&body) {
1273− Ok(req) => {
1274− model.authenticating = true;
1275− // The verify leg belongs to the same attempt as the
1276− // challenge that produced it, so it keeps the generation
1277− // rather than opening a new one.
1278− req.build().then_send(move |r| Event::VerifyResult(gen, r))
1279− }
1280− Err(_) => {
1281− model.conn = ConnStatus::Offline;
1282− model.error = Some("could not build auth request".into());
1283− self.schedule_reconnect(model)
1284− }
1285− }
1286− }
1287− Event::VerifyResult(gen, res) => {
1288− if gen != model.auth_gen {
1289− return Command::done();
1290− }
1291− model.authenticating = false;
1292− let Ok(mut resp) = res else {
1293− model.conn = ConnStatus::Offline;
1294− return self.schedule_reconnect(model);
1295− };
1296− let bytes = resp.take_body().unwrap_or_default();
1297− match serde_json::from_slice::<VerifyResp>(&bytes) {
1298− Ok(v) if !v.token.is_empty() => {
1299− model.token = Some(v.token);
1300− model.conn = ConnStatus::Online;
1301− model.poll_retries = 0;
1302− model.error = None;
1303− Command::event(Event::Poll)
1304− .and(Command::event(Event::StartFlush))
1305− .and(render())
1306− }
1307− _ => {
1308− model.conn = ConnStatus::Offline;
1309− self.schedule_reconnect(model)
1310− }
1311− }
1312− }
1313−
1314− // ---------------- poll ----------------
1315− Event::Poll => {
1316− // Exactly one poll loop at a time: each PollResult re-issues Poll,
1317− // so a second concurrent loop would double forever.
1318− if model.polling {
1319− return Command::done();
1320− }
1321− let Some(token) = model.token.clone() else {
1322− return Command::event(Event::Authenticate);
1323− };
1324− let url = format!("{}/poll", model.settings.server_url);
1325− let body = PollReq {
1326− cursor: model.cursor,
1327− };
1328− match Http::post(url)
1329− .header("authorization", format!("Bearer {token}"))
1330− .body_json(&body)
1331− {
1332− Ok(req) => {
1333− model.polling = true;
1334− let gen = model.next_poll_gen();
1335− // Arm the watchdog alongside the request: if this poll
1336− // never resolves, nothing else will ever clear `polling`.
1337− req.build()
1338− .then_send(move |r| Event::PollResult(gen, r))
1339− .and(
1340− Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1341− .0
1342− .then_send(move |_| Event::PollWatchdog(gen)),
1343− )
1344− }
1345− Err(_) => {
1346− model.error = Some("could not build poll request".into());
1347− self.backoff_poll(model)
1348− }
1349− }
1350− }
1351− Event::PollWatchdog(gen) => {
1352− // Only the poll this watchdog was armed for, and only while it is
1353− // still the current one and still in flight. A watchdog whose poll
1354− // already completed (or was superseded) is a stale timer and must
1355− // not touch a healthy successor.
1356− if gen != model.poll_gen || !model.polling {
1357− return Command::done();
1358− }
1359− // Retire it as we give up on it. The request itself is not
1360− // cancelled — nothing can cancel it — so if it *does* eventually
1361− // resolve, the generation check below is what stops it from
1362− // clearing the replacement poll's guard and forking the loop.
1363− model.abandon_poll();
1364− Command::event(Event::Poll)
1365− }
1366− Event::PollResult(gen, res) => {
1367− // A result for a poll we already abandoned. Dropping the page is
1368− // safe — and required. We never wrote a cursor for it, so the
1369− // relay still holds every message in it and the current poll will
1370− // be handed them again; whereas honouring it here would clear a
1371− // guard that now belongs to a *different*, live poll, and the
1372− // re-poll it chains would be a second loop re-polling itself
1373− // forever alongside the first.
1374− if gen != model.poll_gen {
1375− return Command::done();
1376− }
1377− model.polling = false;
1378− let Ok(mut resp) = res else {
1379− return self.backoff_poll(model);
1380− };
1381− let status = u16::from(resp.status());
1382− if status == 401 {
1383− model.token = None;
1384− return Command::event(Event::Authenticate);
1385− }
1386− if !(200..300).contains(&status) {
1387− return self.backoff_poll(model);
1388− }
1389− model.poll_retries = 0;
1390− // Ingesting a page into a model with a hole in it is the worst thing
1391− // this client can do. The cursor write below is what lets the relay
1392− // delete the mail we just took, so a page ingested into a model that
1393− // cannot persist it is a page destroyed on both sides at once. Stop
1394− // the poll loop instead — it restarts on the next launch, which
1395− // re-runs the loads.
1396− if model.kv_load_failed {
1397− model.error = Some(
1398− "storage read failed — restart the app; messages are not being saved"
1399− .into(),
1400− );
1401− return render();
1402− }
1403− let bytes = resp.take_body().unwrap_or_default();
1404− // Bound the response before parsing: a hostile relay can return
1405− // a multi-gigabyte body that OOMs the app before `ingest_poll`'s
1406− // per-blob budgets are even consulted.
1407− if bytes.len() > MAX_POLL_RESPONSE_BYTES {
1408− model.error = Some("relay sent an oversized poll response".into());
1409− return self.backoff_poll(model);
1410− }
1411− // Q25: A malformed or unparseable poll response must not be
1412− // treated as an empty page — `unwrap_or_default()` would adopt
1413− // cursor 0, forcing a re-fetch from the beginning and wasting
1414− // bandwidth on already-seen (deduped) messages. Back off instead.
1415− let parsed: PollResp = match serde_json::from_slice(&bytes) {
1416− Ok(p) => p,
1417− Err(_) => {
1418− model.error = Some("relay sent a malformed poll response".into());
1419− return self.backoff_poll(model);
1420− }
1421− };
1422− let cursor_before = model.cursor;
1423− let cmd = self.ingest_poll(model, parsed);
1424− // The re-poll hangs off the *cursor write*, not off this event.
1425− // Polling again is what acks the batch we just took — the server
1426− // deletes everything up to `cursor` — so it must not happen until
1427− // the batch is durable on our side. `SavedCursor` re-polls.
1428− //
1429− // Arm a watchdog alongside the cursor write: if the shell ever
1430− // drops the KV effect (suspension, bug), `SavedCursor` never fires,
1431− // `polling` stays `false`, and the loop is dead until the next
1432− // foreground `Connect`. The watchdog fires `Poll` if the loop is
1433− // still idle — same pattern as the poll/send HTTP watchdogs.
1434− //
1435− // Skip the cursor write when the cursor did not advance (an empty
1436− // or fully-deduped page): there is nothing new to persist, so
1437− // re-poll directly. The watchdog is still armed as a fallback.
1438− let cursor_advanced = model.cursor != cursor_before;
1439− cmd.and(if cursor_advanced {
1440− KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1441− .then_send(Event::SavedCursor)
1442− } else {
1443− Command::event(Event::Poll)
1444− })
1445− .and(
1446− Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1447− .0
1448− .then_send(|_| Event::Poll),
1449− )
1450− .and(render())
1451− }
1452−
1453− // ---------------- outbox ----------------
1454− Event::StartFlush => self.flush_next(model),
1455− Event::FlushWatchdog(gen) => {
1456− // Only the flush this watchdog was armed for, and only while it
1457− // is still the current one and still in flight. A watchdog whose
1458− // send already completed (or was superseded) is a stale timer and
1459− // must not touch a healthy successor — same pattern as
1460− // `PollWatchdog`.
1461− if gen != model.flush_gen || !model.flushing {
1462− return Command::done();
1463− }
1464− // Retire it as we give up on it. The request itself is not
1465− // cancelled, so if it eventually resolves the generation check
1466− // in `SendResult` is what stops it from popping the replacement's
1467− // items.
1468− model.abandon_flush();
1469− self.flush_next(model)
1470− }
1471− Event::AuthWatchdog(gen) => {
1472− // Only the auth this watchdog was armed for, and only while it is
1473− // still the current one and still in flight.
1474− if gen != model.auth_gen || !model.authenticating {
1475− return Command::done();
1476− }
1477− model.abandon_auth();
1478− Command::event(Event::Authenticate)
1479− }
1480− Event::SendResult(gen, Ok(resp)) => {
1481− // A result for a send we already abandoned (a watchdog gave up
1482− // on it, or a server change invalidated it). Dropping it is safe
1483− // and required: its items were re-queued, so popping by count
1484− // here would eat whichever messages slid into their place.
1485− if gen != model.flush_gen {
1486− return Command::done();
1487− }
1488− // The in-flight send is over; `flush_next` refuses to run while
1489− // this is set, so it must be cleared before *any* branch below.
1490− model.flushing = false;
1491− let batch = model.in_flight.take();
1492− let status = u16::from(resp.status());
1493− if status == 401 {
1494− model.token = None;
1495− // The batch stays exactly as it is — re-auth, then re-send it.
1496− return Command::event(Event::Authenticate);
1497− }
1498− if status == 429 {
1499− // Rate-limited. Not a delivery failure and not the items'
1500− // fault, so they are not charged a retry — a client that spent
1501− // its delivery budget on the relay's load would drop perfectly
1502− // good messages for being sent too enthusiastically. They stay
1503− // queued untouched; we just wait.
1504− //
1505− // `Retry-After` is the relay saying how long. Only the
1506− // delta-seconds form is read — the HTTP-date form falls back to
1507− // our own backoff — and it is clamped either way, because
1508− // obeying an unbounded number from a relay is how the outbox
1509− // gets parked forever.
1510− let delay = resp
1511− .header("retry-after")
1512− .map(|v| v.last().as_str())
1513− .and_then(|v| v.trim().parse::<u64>().ok())
1514− .map_or_else(
1515− || {
1516− // Nothing to honour, so guess — on the *send* loop's
1517− // counter, not the poll loop's. `poll_retries` is reset
1518− // by every page the poll loop takes, so a client polling
1519− // happily while the relay throttles its sends would
1520− // restart the send backoff at 3 s on every page, forever.
1521− // That is not a backoff; it is a fixed 3 s retry dressed
1522− // as one.
1523− let ms = backoff_ms(model.send_retries);
1524− model.send_retries = model.send_retries.saturating_add(1);
1525− Duration::from_millis(ms)
1526− },
1527− |secs| {
1528− Duration::from_millis(
1529− secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
1530− )
1531− },
1532− );
1533− // Park the send loop for the duration, on the *model*. The timer
1534− // below is not the only thing that fires `StartFlush` — a poll page
1535− // and a fresh `SendText` both do — and without a pause they walk
1536− // straight through the wait and hammer the relay that just asked us
1537− // to stop. `flush_next` refuses to send until this passes, whoever
1538− // wakes it.
1539− model.flush_paused_until =
1540− now_ms().saturating_add(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
1541− return Time::notify_after(delay)
1542− .0
1543− .then_send(|_| Event::StartFlush)
1544− .and(render());
1545− }
1546− if status == 413 {
1547− // The body outgrew what this relay accepts. `MAX_SEND_BATCH_BYTES`
1548− // is only a guess at that cap, and this relay's is smaller — so
1549− // treating the `413` as transient rebuilds the very same batch,
1550− // which is rejected the very same way, forever.
1551− //
1552− // Measure the cap instead: halve what this batch actually
1553− // weighed and rebuild under that. The items are untouched (we
1554− // only pop on 200/400), so there is nothing to re-queue.
1555− //
1556− // Only a *splittable* batch takes this path. A lone item over
1557− // the cap cannot be made any smaller, and halving the budget
1558− // around it would spin — `flush_next` always takes the first
1559− // item whatever it weighs. It falls through to `retry_batch`,
1560− // whose budget is what eventually drops it, with an error the
1561− // user can see.
1562− if let Some(b) = batch.as_ref().filter(|b| b.count > 1) {
1563− model.send_batch_budget = Some(b.bytes / 2);
1564− return self.flush_next(model);
1565− }
1566− }
1567− if (200..300).contains(&status) || status == 400 {
1568− // The batch was accepted, or rejected outright (`self_send` /
1569− // `invalid_message` — both permanent, and the relay takes a
1570− // batch all-or-nothing). Either way it is finished with.
1571− //
1572− // The relay is taking our sends again, so the rate-limit backoff
1573− // has done its job: forget it, or the *next* 429 — possibly hours
1574− // later, under load that has nothing to do with this one — starts
1575− // at whatever height the last one climbed to.
1576− model.send_retries = 0;
1577− // D3-app: Don't pop if kv is broken — the persist would be
1578− // refused, and the items would be lost from memory but still
1579− // on disk, causing duplicate sends on relaunch.
1580− if !model.kv_load_failed {
1581− for _ in 0..batch.map_or(0, |b| b.count) {
1582− model.outbox.pop_front();
1583− }
1584− }
1585− return self.persist_outbox(model).and(self.flush_next(model));
1586− }
1587− // A transient failure: a 5xx, a rate limit, a relay restarting.
1588− self.retry_batch(model, batch)
1589− }
1590− Event::SendResult(gen, Err(_)) => {
1591− // A transport error is the same transient failure as a 5xx.
1592− if gen != model.flush_gen {
1593− return Command::done();
1594− }
1595− model.flushing = false;
1596− let batch = model.in_flight.take();
1597− self.retry_batch(model, batch)
1598− }
1599− }
1600− }
1601−
1602− fn view(&self, model: &Model) -> ViewModel {
1603− let page = match model.page {
1604− Page::Conversations => "conversations",
1605− Page::Chat => "chat",
1606− Page::AddContact => "add_contact",
1607− Page::Settings => "settings",
1608− Page::EditProfile => "edit_profile",
1609− };
1610− let conn = match model.conn {
1611− ConnStatus::Offline => "offline",
1612− ConnStatus::Connecting => "connecting",
1613− ConnStatus::Online => "online",
1614− };
1615−
1616− let active_peer = model.active_peer.clone().unwrap_or_default();
1617−
1618− // `view()` is called after every event the core processes. The
1619− // per-keystroke optimization below assumes `ComposeChanged` fires on
1620− // every keystroke, but the Swift shell only sends it before `SendText`
1621− // — so in practice this path runs once per message, not per keypress.
1622− // The optimization is still correct (it withholds avatars on the chat
1623− // page regardless of how often `view` runs), just the original comment
1624− // overstated the frequency.
1625− let chat_open = model.page == Page::Chat;
1626−
1627− let mut contacts: Vec<ContactVM> = model
1628− .contacts
1629− .values()
1630− .map(|c| {
1631− let msgs = model.messages.get(&c.pubkey);
1632− let last = msgs.and_then(|m| m.last());
1633− let photo = if chat_open && c.pubkey != active_peer {
1634− String::new()
1635− } else {
1636− c.photo.clone().unwrap_or_default()
1637− };
1638− ContactVM {
1639− pubkey: c.pubkey.clone(),
1640− name: c.label(),
1641− ob: hex_to_ob(&c.pubkey),
1642− photo,
1643− blocked: c.blocked,
1644− last_message: last.map(|m| m.body.clone()).unwrap_or_default(),
1645− last_ts: last.map_or(0, |m| m.ts),
1646− }
1647− })
1648− .collect();
1649− contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
1650−
1651− let active_contact = model.contacts.get(&active_peer);
1652− let messages: Vec<MessageVM> = model
1653− .messages
1654− .get(&active_peer)
1655− .map(|m| {
1656− m.iter()
1657− .map(|s| MessageVM {
1658− id: s.id.clone(),
1659− body: s.body.clone(),
1660− ts: s.ts,
1661− outgoing: s.outgoing,
1662− delivered: s.delivered,
1663− })
1664− .collect()
1665− })
1666− .unwrap_or_default();
1667−
1668− ViewModel {
1669− page: page.to_string(),
1670− has_identity: model.secret_key.is_some(),
1671− my_pubkey_hex: model.my_pubkey.clone(),
1672− my_pubkey_ob: hex_to_ob(&model.my_pubkey),
1673− conn_status: conn.to_string(),
1674− server_url: model.settings.server_url.clone(),
1675− profile: ProfileVM {
1676− display_name: model.profile.display_name.clone(),
1677− bio: model.profile.bio.clone(),
1678− photo: model.profile.photo.clone().unwrap_or_default(),
1679− },
1680− contacts,
1681− active_peer: active_peer.clone(),
1682− active_peer_name: active_contact.map_or_else(
1683− || trunc_ob(&active_peer),
1684− Contact::label,
1685− ),
1686− active_peer_ob: hex_to_ob(&active_peer),
1687− active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
1688− messages,
1689− error: model.error.clone().unwrap_or_default(),
1690− }
1691− }
1692−}
1693−
1694−impl Skrepka {
1695− /// One of the five startup kv loads landed. Fires `Connect` on the last one.
1696− ///
1697− /// Connecting earlier would let a poll land — and be persisted — against a
1698− /// model that is still missing its contacts, cursor, or outbox. The loads
1699− /// still in flight would then overwrite what the poll just ingested, and the
1700− /// stale `cursor` (0) would make us re-fetch mail we had already acked.
1701− fn startup_done(&self, model: &mut Model) -> Command<Effect, Event> {
1702− if !model.startup_load_done() {
1703− return Command::done();
1704− }
1705− if model.kv_load_failed {
1706− // One of the loads could not be read (as opposed to being absent), so
1707− // the model has a hole in it over data that is still on disk. Do not
1708− // connect: a poll would ingest mail into that hole and ack it, and the
1709− // relay would then delete the only copy of messages we never persisted.
1710− //
1711− // Everything downstream is already write-guarded, so this is belt and
1712− // braces — but it is also the only place that can *say* so, and the
1713− // recovery is a relaunch, which re-runs the loads.
1714− model.error = Some(
1715− "storage read failed — restart the app; changes will not be saved until you do"
1716− .into(),
1717− );
1718− return render();
1719− }
1720− Command::event(Event::Connect)
1721− }
1722−
1723− /// The three model-state writes, and the one rule they share: a model that
1724− /// failed to read a key must never write one.
1725− ///
1726− /// `persist_outbox` is the reason this matters. `ingest_poll` calls it on every
1727− /// poll page — including an empty one — so a failed outbox read followed by a
1728− /// single idle poll is enough to write `[]` over a queue of real unsent
1729− /// messages. The user's mail is destroyed by a client doing nothing at all.
1730− fn refuse_write(&self, model: &mut Model) -> bool {
1731− if model.kv_load_failed {
1732− model.error = Some("storage read failed — changes will not be saved".into());
1733− return true;
1734− }
1735− false
1736− }
1737−
1738− fn persist_contacts(&self, model: &mut Model) -> Command<Effect, Event> {
1739− if self.refuse_write(model) {
1740− return Command::done();
1741− }
1742− let list: Vec<Contact> = model.contacts.values().cloned().collect();
1743− KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
1744− }
1745−
1746− fn persist_profile(&self, model: &mut Model) -> Command<Effect, Event> {
1747− if self.refuse_write(model) {
1748− return Command::done();
1749− }
1750− KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved)
1751− }
1752−
1753− fn persist_outbox(&self, model: &mut Model) -> Command<Effect, Event> {
1754− if self.refuse_write(model) {
1755− return Command::done();
1756− }
1757− let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
1758− KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
1759− }
1760−
1761− fn persist_messages(&self, model: &mut Model, peer: &str) -> Command<Effect, Event> {
1762− if self.refuse_write(model) {
1763− return Command::done();
1764− }
1765− let list = model.messages.get(peer).cloned().unwrap_or_default();
1766− KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
1767− }
1768−
1769− /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1770− /// the set survives restarts and covers IDs that `trim_history` has aged
1771− /// out of the conversation.
1772− fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1773− if self.refuse_write(model) {
1774− return Command::done();
1775− }
1776− KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1777− }
1778−
1779− fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
1780− let delay = backoff_ms(model.poll_retries);
1781− model.poll_retries = model.poll_retries.saturating_add(1);
1782− Time::notify_after(Duration::from_millis(delay))
1783− .0
1784− .then_send(|_| Event::Authenticate)
1785− .and(render())
1786− }
1787−
1788− /// The poll loop stalled — retry after a backoff, and stop claiming to be online.
1789− ///
1790− /// Every path here (a transport error, a 5xx, a failed cursor write) means the loop
1791− /// is not healthy, so leaving `conn` at `Online` both lies to the status dot and —
1792− /// worse — makes `Connect` a no-op, because it stands down when it already believes
1793− /// it is online. The shell fires `Connect` on every foreground precisely to recover
1794− /// the long-poll that backgrounding killed; without this it recovers nothing.
1795− fn backoff_poll(&self, model: &mut Model) -> Command<Effect, Event> {
1796− model.conn = ConnStatus::Offline;
1797− let delay = backoff_ms(model.poll_retries);
1798− model.poll_retries = model.poll_retries.saturating_add(1);
1799− Time::notify_after(Duration::from_millis(delay))
1800− .0
1801− .then_send(|_| Event::Poll)
1802− .and(render())
1803− }
1804−
1805− fn send_text(&self, model: &mut Model) -> Command<Effect, Event> {
1806− let Some(peer) = model.active_peer.clone() else {
1807− return render();
1808− };
1809− // Clear a stale error from a previous failed send, so the error display
1810− // does not persist after the user fixes the issue and sends again.
1811− model.error = None;
1812− // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1813− // ingest side drops their messages; the send side must refuse too, or
1814− // a blocked peer still receives our texts, acks, and the liveness
1815− // signal that blocking is supposed to cut off.
1816− if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1817− model.error = Some("cannot send to a blocked contact".into());
1818− return render();
1819− }
1820− let body = model.compose.trim().to_string();
1821− if body.is_empty() {
1822− return render();
1823− }
1824− // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1825− // drops oversized text. Without this check the message would be stored
1826− // locally, sent to the relay, and marked "sent" — but never arrive, never
1827− // get acked, and the sender would see it stuck as "sent" forever with no
1828− // error.
1829− if body.len() > protocol::MAX_BODY_LEN {
1830− model.error = Some("message too long".into());
1831− return render();
1832− }
1833−
1834− // Check the contact cap *before* mutating anything. The old order
1835− // cleared `compose` and inserted the message into `model.messages`
1836− // first, then hit the cap and returned early — so the user's text was
1837− // gone (compose cleared), the message sat in an invisible orphan
1838− // conversation (no contact entry to click on), and nothing was
1839− // persisted (no `persist_messages` before the early return). On next
1840− // launch the message was silently lost.
1841− let was_new = !model.contacts.contains_key(&peer);
1842− if was_new && model.contacts.len() >= MAX_CONTACTS {
1843− model.error = Some("contact list is full".into());
1844− return render();
1845− }
1846−
1847− model.compose.clear();
1848− let ts = now_ms();
1849− let id = uuid::Uuid::new_v4().to_string();
1850−
1851− let convo = model.messages.entry(peer.clone()).or_default();
1852− // Insert in order rather than appending: an incoming message may hold a
1853− // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1854− // the conversation unsorted — and `trim_history` ages out the *front*.
1855− insert_sorted(
1856− convo,
1857− StoredMessage {
1858− id: id.clone(),
1859− body: body.clone(),
1860− ts,
1861− outgoing: true,
1862− delivered: false,
1863− },
1864− );
1865− trim_history(convo);
1866−
1867− // Auto-create a bare contact if messaging a brand-new key, respecting
1868− // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1869− // user-driven (not an attack vector), but the cap exists to bound the
1870− // `contacts` kv blob, which is rewritten in full on every change.
1871− if was_new {
1872− model
1873− .contacts
1874− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1875− }
1876−
1877− let text = Payload::Text { id, body };
1878− model.outbox.push_back(OutboxItem::new(
1879− peer.clone(),
1880− Arc::new(protocol::serialize_payload(&text, ts)),
1881− ));
1882−
1883− // On first contact, share our profile *after* the text. The text
1884− // auto-creates the contact on the recipient's side; the profile that
1885− // follows in the same batch then applies to the now-existing contact.
1886− // (Previously profile was queued before the text, but the recipient's
1887− // ingest drops profiles from non-contacts, so the profile was lost.)
1888− if was_new {
1889− let profile = Payload::Profile {
1890− display_name: Some(model.profile.display_name.clone()),
1891− bio: Some(model.profile.bio.clone()),
1892− photo: model.profile.photo.clone(),
1893− };
1894− model.outbox.push_back(OutboxItem::profile(
1895− peer.clone(),
1896− Arc::new(protocol::serialize_payload(&profile, ts)),
1897− ));
1898− }
1899−
1900− Command::all([
1901− self.persist_messages(model, &peer),
1902− self.persist_contacts(model),
1903− self.persist_outbox(model),
1904− ])
1905− .and(Command::event(Event::StartFlush))
1906− .and(render())
1907− }
1908−
1909− /// Encrypt and send a batch from the head of the outbox, discarding any
1910− /// unsendable items in the way.
1911− ///
1912− /// The batch is the longest run of items at the head that share a recipient,
1913− /// bounded by `MAX_SEND_BATCH` and `MAX_SEND_BATCH_BYTES`. Sending them one
1914− /// at a time meant an HTTP round-trip per message — and per *ack*, which
1915− /// `ingest_poll` queues one of per sender per page, so catching up after a
1916− /// long offline stretch was one request per message received. The relay takes
1917− /// up to a hundred per request (PROTOCOL.md §7); take it up on that.
1918− ///
1919− /// Stopping at the first recipient change keeps the outbox's FIFO ordering
1920− /// intact — a batch is delivered as a unit, so hoisting a later item for the
1921− /// same peer over an earlier one for a different peer could only reorder
1922− /// *across* conversations, but it would also let one busy peer starve the
1923− /// rest of the queue. Consecutive-only is the cheap, obviously-fair rule, and
1924− /// it already collapses the two cases that matter: a burst of texts to the
1925− /// open chat, and a page's worth of acks back to one sender.
1926− ///
1927− /// The discards happen in one pass, followed by a single `persist_outbox`.
1928− /// The previous shape recursed once per bad item and emitted a full outbox
1929− /// rewrite each time, so a contact list holding `n` unusable keys cost
1930− /// O(n) kv writes of an O(n) blob — and `n` stack frames — to reach the first
1931− /// item that could actually go out.
1932− fn flush_next(&self, model: &mut Model) -> Command<Effect, Event> {
1933− if model.flushing {
1934− return Command::done();
1935− }
1936− // A failed startup read means the outbox in memory is not the outbox on
1937− // disk. Sending from it is not the danger — `persist_outbox` refusing to
1938− // write is — but a send that cannot record having happened is one that will
1939− // happen again on the next launch, from the queue still on disk. Sit still.
1940− if model.kv_load_failed {
1941− return Command::done();
1942− }
1943− // A 429 parked the loop until the far side of the relay's `Retry-After`.
1944− // The timer that set the pause also scheduled the `StartFlush` that resumes
1945− // us, so whoever woke us early — a poll page, a fresh `SendText` — the
1946− // answer is the same: the relay asked us to stop until then.
1947− if now_ms() < model.flush_paused_until {
1948− return Command::done();
1949− }
1950− let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
1951− return Command::done();
1952− };
1953−
1954− let now = now_ms();
1955− let mut dirty = false;
1956− let mut recipient: Option<String> = None;
1957− let mut messages: Vec<Envelope> = Vec::new();
1958− let mut bytes = 0usize;
1959− // What we believe this relay's body cap to be — `MAX_SEND_BATCH_BYTES`
1960− // until a `413` tells us otherwise.
1961− let batch_budget = model.send_batch_budget.unwrap_or(MAX_SEND_BATCH_BYTES);
1962−
1963− while messages.len() < MAX_SEND_BATCH {
1964− let Some(item) = model.outbox.get(messages.len()) else {
1965− break;
1966− };
1967− if recipient.as_ref().is_some_and(|r| r != &item.recipient) {
1968− break;
1969− }
1970− let Some(blob) = encrypt_for(&id, item) else {
1971− // An unusable recipient key can never be encrypted to, so the item
1972− // has to go or it blocks the head of the outbox forever — but say
1973− // so, rather than letting the message vanish. (Only reachable at
1974− // the head: an item deeper in the batch shares the recipient of one
1975− // we have already encrypted to, so its key is good by construction.)
1976− model.error = Some(format!(
1977− "cannot send to {}: invalid key",
1978− trunc_ob(&item.recipient)
1979− ));
1980− model.outbox.remove(messages.len());
1981− dirty = true;
1982− continue;
1983− };
1984− let hex_blob = hex::encode(blob);
1985− // Always take the first item, whatever it weighs — a lone oversized
1986− // payload must still reach the relay, which rejects it permanently
1987− // (`400`) and so gets it out of the queue for good. Past the first,
1988− // stop before the body outgrows what a relay will accept.
1989− if !messages.is_empty() && bytes + hex_blob.len() > batch_budget {
1990− break;
1991− }
1992− bytes += hex_blob.len();
1993− recipient = Some(item.recipient.clone());
1994− messages.push(Envelope {
1995− to: item.recipient.clone(),
1996− encrypted_blob: hex_blob,
1997− });
1998− }
1999−
2000− let Some(recipient) = recipient else {
2001− // Nothing left to send. Render only if we dropped something, so the
2002− // user sees why their message went away.
2003− return if dirty {
2004− self.persist_outbox(model).and(render())
2005− } else {
2006− Command::done()
2007− };
2008− };
2009−
2010− // Stamp the items now going out for the first time. This is what the TTL
2011− // in `OutboxItem::is_expired` measures from, so it has to be durable —
2012− // otherwise a relaunch resets the clock and a permanently-stuck item is
2013− // immortal again.
2014− for item in model.outbox.iter_mut().take(messages.len()) {
2015− if item.first_attempt == 0 {
2016− item.first_attempt = now;
2017− dirty = true;
2018− }
2019− }
2020− let persist = if dirty {
2021− self.persist_outbox(model)
2022− } else {
2023− Command::done()
2024− };
2025−
2026− model.flushing = true;
2027− let gen = model.next_flush_gen();
2028− model.in_flight = Some(InFlight {
2029− recipient,
2030− count: messages.len(),
2031− bytes,
2032− });
2033− let url = format!("{}/messages", model.settings.server_url);
2034− match Http::post(url)
2035− .header("authorization", format!("Bearer {token}"))
2036− .body_json(&SendBatch { messages })
2037− {
2038− Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
2039− .and(
2040− Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2041− .0
2042− .then_send(move |_| Event::FlushWatchdog(gen)),
2043− ),
2044− Err(_) => {
2045− // Not a dead loop: the next StartFlush (from a poll page or a
2046− // fresh send) retries the head of the outbox.
2047− model.flushing = false;
2048− model.in_flight = None;
2049− model.error = Some("could not build send request".into());
2050− persist.and(render())
2051− }
2052− }
2053− }
2054−
2055− /// A send failed transiently (a 5xx, a rate limit, a dropped connection). The
2056− /// batch stays queued and goes out again on the next `StartFlush` — but not
2057− /// forever.
2058− ///
2059− /// The outbox is a strict FIFO, so an item that can never be delivered blocks
2060− /// every message queued behind it, permanently: a single unreachable recipient
2061− /// relay used to be enough to silence the client for good. Charge every item in
2062− /// the failed batch a retry and drop the ones that have exhausted their budget
2063− /// (`OutboxItem::is_expired`) — with an error, rather than letting them vanish.
2064− fn retry_batch(&self, model: &mut Model, batch: Option<InFlight>) -> Command<Effect, Event> {
2065− // `None` means the items were removed out from under the send (the peer was
2066− // blocked, or deleted). There is nothing left to charge or to drop.
2067− let Some(batch) = batch else {
2068− return render();
2069− };
2070−
2071− let now = now_ms();
2072− let mut expired = 0usize;
2073− let mut kept: VecDeque<OutboxItem> = VecDeque::with_capacity(model.outbox.len());
2074− for (i, mut item) in std::mem::take(&mut model.outbox).into_iter().enumerate() {
2075− if i < batch.count {
2076− item.retries = item.retries.saturating_add(1);
2077− if item.is_expired(now) {
2078− expired += 1;
2079− continue;
2080− }
2081− }
2082− kept.push_back(item);
2083− }
2084− model.outbox = kept;
2085−
2086− if expired == 0 {
2087− // Nothing dropped, but the retry counters still have to be durable: a
2088− // relaunch that handed every stuck item a fresh budget would wedge the
2089− // outbox across restarts exactly as it did before there was a budget.
2090− return self.persist_outbox(model).and(render());
2091− }
2092− model.error = Some(format!(
2093− "gave up sending {expired} message(s) to {}",
2094− trunc_ob(&batch.recipient)
2095− ));
2096− // The head of the outbox has moved, so whatever was stuck behind the items
2097− // we just dropped can finally go out.
2098− self.persist_outbox(model)
2099− .and(self.flush_next(model))
2100− .and(render())
2101− }
2102−
2103− /// Decrypt and fold a poll page into the model; queue delivery acks.
2104− fn ingest_poll(&self, model: &mut Model, page: PollResp) -> Command<Effect, Event> {
2105− // The caller already refuses to poll into a broken model, and must — the
2106− // cursor write is its own, and advancing it is what acks the page. This
2107− // stands guard for whoever calls this next: ingesting is only safe if what
2108− // is ingested can be written down.
2109− if model.kv_load_failed {
2110− return Command::done();
2111− }
2112− let prev_cursor = model.cursor;
2113− // Clamp the relay-supplied cursor: honest cursors are wall-clock-seeded
2114− // sequence numbers, so anything far in the future is a hostile or buggy
2115− // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
2116− // next poll and persist that poison across relaunches.
2117− let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
2118− // Q16: Only advance the cursor when the page actually carried events.
2119− // An empty page with a cursor higher than ours is suspicious — a
2120− // compromised relay could poison the cursor to ack (delete) messages
2121− // the client hasn't seen. When there are no events, there is nothing
2122− // to acknowledge, so the cursor should not move. (An honest relay's
2123− // empty-page cursor equals what the client sent, so this is a no-op
2124− // in the normal case.)
2125− if !page.events.is_empty() && clamped_cursor > model.cursor {
2126− model.cursor = clamped_cursor;
2127− }
2128− let Some(id) = model.identity() else {
2129− return Command::done();
2130− };
2131−
2132− let now = now_ms();
2133− let mut touched_convos: HashSet<String> = HashSet::new();
2134− let mut contacts_dirty = false;
2135− let mut outbox_dirty = false;
2136− // sender hex -> message ids needing acks
2137− let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
2138− // Q17: Per-sender deferred ack accumulation. Collected during the event
2139− // loop, applied after it — so relay page ordering cannot cause an older
2140− // ack to be dropped as stale by a newer one processed first.
2141− let mut pending_acks: HashMap<String, PendingAck> = HashMap::new();
2142−
2143− let mut budget = MAX_POLL_TOTAL_BYTES;
2144− let mut events_processed = 0usize;
2145− // N9: Track whether any event was successfully decrypted and stored.
2146− // When the budget/count is exhausted mid-page, we ack the page (keep
2147− // the advanced cursor) only if at least one event was processed —
2148− // the unprocessed tail is from a hostile/misconfigured relay and
2149− // losing them is better than livelocking. If zero events were
2150− // processed (a completely hostile page), restore the cursor so we
2151− // don't ack mail we never saw.
2152− let mut events_stored = 0usize;
2153− for ev in page.events {
2154− // Reject an oversized blob on its hex length, before it costs us a
2155− // decode. No conforming sender produces one (the relay caps blobs at
2156− // the same size), so this can only be junk.
2157− if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
2158− continue;
2159− }
2160− // Event count cap: the byte budget alone permits ~388K minimum-size
2161− // events, each costing a full decrypt attempt. A hostile relay can
2162− // freeze the app for tens of seconds per poll with a page of small
2163− // junk blobs. Stop processing past this count.
2164− if events_processed >= MAX_POLL_EVENTS {
2165− // N9: If we stored at least one event, keep the advanced cursor
2166− // to ack the page and avoid a livelock. The unprocessed events
2167− // are hostile junk or beyond our capacity — we can't process
2168− // them anyway, and re-fetching them forever would wedge the
2169− // client.
2170− if events_stored == 0 {
2171− model.cursor = prev_cursor;
2172− }
2173− model.error = Some("relay sent too many poll events".into());
2174− break;
2175− }
2176− events_processed += 1;
2177− match budget.checked_sub(ev.encrypted_blob.len() / 2) {
2178− Some(left) => budget = left,
2179− None => {
2180− // N9: Budget exhausted. If we stored at least one event,
2181− // keep the advanced cursor to ack the page — re-fetching
2182− // an oversized page forever is a livelock. The unprocessed
2183− // events are beyond our capacity and would be re-served
2184− // on every poll, wedging the client. Acking them breaks
2185− // the loop. If zero events were stored (completely hostile
2186− // page), restore the cursor to avoid acking mail we
2187− // never saw.
2188− if events_stored == 0 {
2189− model.cursor = prev_cursor;
2190− }
2191− model.error = Some("relay sent an oversized poll page".into());
2192− break;
2193− }
2194− }
2195− let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
2196− continue;
2197− };
2198− let Ok(dec) = crate::crypto::decrypt(&id, &blob) else {
2199− continue;
2200− };
2201− let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
2202− continue;
2203− };
2204− // D1: Any event that was successfully decrypted AND parsed counts
2205− // as "stored" for cursor advancement — the client has processed it
2206− // even if it was a duplicate/stale/stranger that was dropped.
2207− events_stored += 1;
2208− let sender = dec.sender_hex;
2209−
2210− // Mail from ourselves. A relay cannot forge this — the sender identity
2211− // is signed inside the AEAD — but it can *echo back* a blob we sent
2212− // through it, and one addressed to ourselves decrypts perfectly well.
2213− // Nothing downstream would notice: it would auto-create a contact for
2214− // our own key (which `AddContact` explicitly refuses to do), file our
2215− // own words as an incoming message, and queue a delivery ack addressed
2216− // to us — which flushes, comes back, and is acked again.
2217− if sender == model.my_pubkey {
2218− continue;
2219− }
2220−
2221− // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
2222− // their texts. A blocked peer must not be able to rewrite their
2223− // display name and avatar in our contact list, mark our messages
2224− // delivered, or (via an ack we send back) learn that we are online
2225− // and reading. So the gate sits above the payload match, not inside
2226− // the `Text` arm.
2227− if model.contacts.get(&sender).is_some_and(|c| c.blocked) {
2228− continue;
2229− }
2230−
2231− // `ts` is chosen by the sender and drives conversation ordering,
2232− // in-chat ordering, and the displayed time. Bound it to the near
2233− // future so a peer cannot pin itself to the top of the list forever
2234− // with `ts = i64::MAX`, and bound it to the non-negative past so a
2235− // malicious peer cannot make their own message invisible by dating
2236− // it at `i64::MIN` — `insert_sorted` would put it at position 0 and
2237− // `trim_history` would age it out first.
2238− let ts = parsed
2239− .ts
2240− .max(0)
2241− .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
2242− let known = model.contacts.contains_key(&sender);
2243− match parsed.payload {
2244− Payload::Text { id: msg_id, body } => {
2245− // Auto-create a contact for a stranger who writes to us — but
2246− // only up to `MAX_CONTACTS`. Past that, drop the message
2247− // entirely: storing it would mean a `messages:<peer>` blob for
2248− // a peer with no contact entry, i.e. a conversation nothing
2249− // can open and nothing ever trims.
2250− if !known {
2251− if model.contacts.len() >= MAX_CONTACTS {
2252− continue;
2253− }
2254− model.contacts.insert(
2255− sender.clone(),
2256− Contact::new(sender.clone(), String::new(), ts),
2257− );
2258− contacts_dirty = true;
2259− // D6-app: Send our profile to the new contact so they
2260− // see our display name and avatar, not just our @p.
2261− let profile = Payload::Profile {
2262− display_name: Some(model.profile.display_name.clone()),
2263− bio: Some(model.profile.bio.clone()),
2264− photo: model.profile.photo.clone(),
2265− };
2266− model.outbox.push_back(OutboxItem::profile(
2267− sender.clone(),
2268− Arc::new(protocol::serialize_payload(&profile, ts)),
2269− ));
2270− outbox_dirty = true;
2271− }
2272− let convo = model.messages.entry(sender.clone()).or_default();
2273− // Q18: Use the persistent per-peer dedup set from
2274− // `model.seen_ids` (loaded from kv at startup) instead of
2275− // deriving it from the trimmed conversation. Without this,
2276− // a replayed blob whose `id` was aged out by `trim_history`
2277− // (past the 1000-message cap) passes the dedup check and
2278− // re-enters the conversation.
2279− let id_set = model
2280− .seen_ids
2281− .entry(sender.clone())
2282− .or_default();
2283− // Also seed from the conversation in case the kv load was
2284− // missing or partial — covers the upgrade path.
2285− if id_set.is_empty() {
2286− id_set.extend(convo.iter().map(|m| m.id.clone()));
2287− }
2288− // dedup by id — O(1) via HashSet
2289− if !id_set.contains(&msg_id) {
2290− // The conversation is *kept* sorted rather than re-sorted:
2291− // a `sort_by_key` per arrival is O(n log n) on a list of up
2292− // to `MAX_MESSAGES_PER_PEER`, every time, and a poll page
2293− // can carry hundreds of messages.
2294− insert_sorted(
2295− convo,
2296− StoredMessage {
2297− id: msg_id.clone(),
2298− body,
2299− ts,
2300− outgoing: false,
2301− delivered: false,
2302− },
2303− );
2304− id_set.insert(msg_id.clone());
2305− // N7: Cap the dedup set so it doesn't grow without
2306− // bound. The set is rewritten to kv on every message
2307− // receipt, so an unbounded set makes every receipt
2308− // slower. Keep the most recent IDs by draining excess
2309− // from the oldest. `HashSet` has no ordering, so when
2310− // the cap is exceeded we rebuild from the conversation
2311− // (which is trimmed to MAX_MESSAGES_PER_PEER and
2312− // sorted by ts) — the oldest IDs in the set that are
2313− // no longer in the conversation are the ones to drop.
2314− if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2315− let convo_ids: HashSet<&String> =
2316− convo.iter().map(|m| &m.id).collect();
2317− let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2318− let to_remove: Vec<String> = id_set
2319− .iter()
2320− .filter(|id| !convo_ids.contains(*id))
2321− .take(excess)
2322− .cloned()
2323− .collect();
2324− for id in to_remove {
2325− id_set.remove(&id);
2326− }
2327− }
2328− trim_history(convo);
2329− touched_convos.insert(sender.clone());
2330− // Only ack a message we actually stored — a duplicate id
2331− // was already acked when its first copy was ingested.
2332− ack_targets.entry(sender.clone()).or_default().push(msg_id);
2333− }
2334− }
2335− Payload::DeliveryAck { ack_ids } => {
2336− // Q17: Defer the staleness check until after the event loop.
2337− // Within a single page, a relay can reorder events so that a
2338− // newer ack (higher ts) is processed before an older one. If
2339− // we applied the watermark immediately, the older ack would be
2340− // dropped as stale — even if it acks different message IDs.
2341− // Instead, accumulate all ack_ids per sender and the max ts,
2342− // then apply them in a second pass below the loop.
2343− let prev_ts = pending_acks
2344− .get(&sender)
2345− .map(|p| p.ts)
2346− .unwrap_or(0);
2347− if ts > prev_ts {
2348− let entry = pending_acks.entry(sender.clone()).or_default();
2349− entry.ts = ts;
2350− }
2351− pending_acks
2352− .entry(sender.clone())
2353− .or_default()
2354− .ids
2355− .extend(ack_ids);
2356− }
2357− Payload::Profile {
2358− display_name,
2359− bio,
2360− photo,
2361− } => {
2362− // A bare profile from a stranger creates nothing. It is an
2363− // unsolicited push with no message attached, so honouring it
2364− // would let anyone holding our pubkey put an entry (with an
2365− // avatar) in our contact list without ever saying a word.
2366− let Some(c) = model.contacts.get_mut(&sender) else {
2367− continue;
2368− };
2369− // Ignore stale profile replays (PROTOCOL.md §4). The watermark
2370− // is clamped to *now* for the same reason `last_ack_ts` is: a
2371− // peer that parked it in the future would freeze its own
2372− // profile, since every honest update it sent afterwards would
2373− // read as a stale replay of itself.
2374− //
2375− // `>=` (not `>`): a contact's `last_profile_ts` starts at 0,
2376− // and a first profile whose `ts` is also 0 — the default when
2377− // the field is missing — must still be accepted. `>` would
2378− // silently drop it, leaving the contact with no profile at all.
2379− // A replayed profile at the same `ts` overwrites with identical
2380− // content, which is a no-op in practice.
2381− if ts >= c.last_profile_ts {
2382− // D13: Only update fields that are present (Some).
2383− // An absent field (None) means "don't change".
2384− if let Some(name) = display_name {
2385− c.display_name = name;
2386− }
2387− if let Some(b) = bio {
2388− c.bio = b;
2389− }
2390− if let Some(p) = photo {
2391− c.photo = Some(p);
2392− }
2393− c.last_profile_ts = ts.min(now);
2394− contacts_dirty = true;
2395− }
2396− }
2397− }
2398− }
2399−
2400− // Q17: Apply deferred delivery acks. Collected during the event loop
2401− // above, now applied with a single staleness check per sender using the
2402− // highest `ts` seen — so relay page ordering cannot cause an older ack
2403− // to be dropped as stale by a newer one processed first.
2404− for (sender, pending) in &pending_acks {
2405− let stale = model
2406− .contacts
2407− .get(sender)
2408− .is_some_and(|c| pending.ts < c.last_ack_ts);
2409− if stale {
2410− continue;
2411− }
2412− // The watermark is clamped to *now*, not merely into the skew window:
2413− // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2414− // ack is measured against. A single ack at `ts = i64::MAX` would
2415− // otherwise park the watermark a minute into the future, and every
2416− // honest ack that followed — the ones that actually mark our messages
2417− // delivered — would read as stale and be dropped.
2418− if let Some(c) = model.contacts.get_mut(sender) {
2419− c.last_ack_ts = pending.ts.min(now);
2420− contacts_dirty = true;
2421− }
2422− if let Some(convo) = model.messages.get_mut(sender) {
2423− let mut changed = false;
2424− for m in convo.iter_mut() {
2425− if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2426− m.delivered = true;
2427− changed = true;
2428− }
2429− }
2430− if changed {
2431− touched_convos.insert(sender.clone());
2432− }
2433− }
2434− }
2435−
2436− // Queue delivery acks, in batches the recipient will actually accept.
2437− //
2438− // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
2439− // ids, and a peer running this same code applies that cap to *us*. One
2440− // payload per sender therefore silently stops working the moment a peer
2441− // has more than `MAX_ACK_IDS` messages waiting for us — which is exactly
2442− // what a long offline stretch produces — and every one of those messages
2443− // stays un-delivered on their side forever, because a poll page is only
2444− // ever acked once.
2445− let ack_ts = now_ms();
2446− for (sender, ids) in ack_targets {
2447− for batch in ids.chunks(protocol::MAX_ACK_IDS) {
2448− let payload = Payload::DeliveryAck {
2449− ack_ids: batch.to_vec(),
2450− };
2451− // Consecutive and same-recipient, so `flush_next` puts every one of
2452− // a sender's ack batches into a single `/messages` request.
2453− model.outbox.push_back(OutboxItem::new(
2454− sender.clone(),
2455− Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2456− ));
2457− }
2458− outbox_dirty = true;
2459− }
2460−
2461− // Persist everything touched.
2462− let mut cmds: Vec<Command<Effect, Event>> = Vec::new();
2463− for peer in &touched_convos {
2464− cmds.push(self.persist_messages(model, peer));
2465− }
2466− // Q18: Persist the dedup ID set whenever we touched conversations.
2467− if !touched_convos.is_empty() {
2468− cmds.push(self.persist_seen_ids(model));
2469− }
2470− if contacts_dirty {
2471− cmds.push(self.persist_contacts(model));
2472− }
2473− if outbox_dirty {
2474− cmds.push(self.persist_outbox(model));
2475− }
2476− cmds.push(Command::event(Event::StartFlush));
2477− Command::all(cmds)
2478− }
2479−}
2480−
2481−/// Encrypt one outbox item to its recipient, or `None` if the recipient's key is
2482−/// unusable — a bad hex string, the wrong length, or 32 bytes that aren't a curve
2483−/// point. `AddContact` rejects all three now, but an older contact list (or a
2484−/// profile-broadcast fan-out over one) may still hold such a key.
2485−fn encrypt_for(sender: &Identity, item: &OutboxItem) -> Option<Vec<u8>> {
2486− let recipient: [u8; 32] = hex::decode(&item.recipient).ok()?.try_into().ok()?;
2487− let mut rng = rand_core::OsRng;
2488− crate::crypto::encrypt(&mut rng, sender, &recipient, item.envelope_json.as_bytes()).ok()
2489−}
2490−
2491−fn backoff_ms(retries: u32) -> u64 {
2492− (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
2493−}
2494−
2495−/// Insert a message into a conversation held in `ts` order.
2496−///
2497−/// `partition_point` lands *after* every message sharing the new one's `ts`, so
2498−/// ties keep arrival order — exactly what the stable `sort_by_key` this replaces
2499−/// produced, at O(log n) + a shift instead of O(n log n) on every message.
2500−///
2501−/// This is what makes "sorted by `ts`" an invariant rather than something
2502−/// `ingest_poll` restored after the fact, which is what `trim_history` has always
2503−/// assumed (it ages out the *front*) and what `view()` renders in order.
2504−fn insert_sorted(convo: &mut Vec<StoredMessage>, msg: StoredMessage) {
2505− let pos = convo.partition_point(|m| m.ts <= msg.ts);
2506− convo.insert(pos, msg);
2507−}
2508−
2509−/// Age out local history: keep only the most recent `MAX_MESSAGES_PER_PEER`
2510−/// messages of a conversation (PROTOCOL.md §9). Callers keep `convo` sorted by
2511−/// `ts`, so the oldest messages are at the front. The subsequent kv write
2512−/// persists the trimmed list, bounding the `messages:<peer>` blob too.
2513−fn trim_history(convo: &mut Vec<StoredMessage>) {
2514− if let Some(excess) = convo.len().checked_sub(MAX_MESSAGES_PER_PEER) {
2515− convo.drain(..excess);
2516− }
2517−}
2518−
2519−/// Decode a kv `get` result into a typed value.
2520−///
2521−/// Returns `Ok(Some(value))` when the key exists and deserializes, `Ok(None)`
2522−/// when the key is absent (a fresh install), and `Err` when the read itself
2523−/// failed (e.g. the file is locked or corrupt). The caller MUST NOT overwrite
2524−/// a key with a default when the read failed — the data is still on disk, and
2525−/// writing the default back destroys it. An absent key is safe to default.
2526−///
2527−/// The old `parse_kv` conflated read errors with absence, which meant a
2528−/// background wake while the device was locked (where `completeUnlessOpen`
2529−/// refuses to open existing files) would load an empty model, and the next
2530−/// `persist_*` would write that emptiness back over the real data.
2531−fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Result<Option<T>, ()> {
2532− match res {
2533− Ok(Some(bytes)) => serde_json::from_slice(&bytes)
2534− .map(Some)
2535− .map_err(|_| ()),
2536− Ok(None) => Ok(None),
2537− Err(_) => Err(()),
2538− }
2539−}
2540−
2541−#[cfg(test)]
2542−mod tests {
2543− use super::*;
2544− use crate::crypto::Identity;
2545− use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
2546−
2547− fn with_identity() -> Model {
2548− let id = Identity::from_seed(&[3u8; 32]).unwrap();
2549− Model {
2550− secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
2551− my_pubkey: id.public_key_hex(),
2552− ..Default::default()
2553− }
2554− }
2555−
2556− fn peer_hex(seed: u8) -> String {
2557− Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
2558− }
2559−
2560− /// `Command::event` *queues* an event; it does not run it. The runtime drains
2561− /// that queue back into `update`, so a test that stops at the first `update`
2562− /// only ever sees half of what an event actually does — `Connect` would look
2563− /// like it never authenticates, and the poll watchdog like it never re-polls.
2564− /// Drain it the way the runtime does.
2565− fn drive(app: &Skrepka, cmd: &mut Command<Effect, Event>, model: &mut Model) {
2566− for event in cmd.events().collect::<Vec<_>>() {
2567− let mut next = app.update(event, model);
2568− drive(app, &mut next, model);
2569− }
2570− }
2571−
2572− /// `update` + drain, i.e. what the shell actually observes.
2573− fn dispatch(app: &Skrepka, event: Event, model: &mut Model) {
2574− let mut cmd = app.update(event, model);
2575− drive(app, &mut cmd, model);
2576− }
2577−
2578− /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
2579− fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
2580− let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
2581− let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
2582− let json = protocol::serialize_payload(payload, ts);
2583− let blob = crate::crypto::encrypt(
2584− &mut rand_core::OsRng,
2585− &sender,
2586− &recipient.public_key(),
2587− json.as_bytes(),
2588− )
2589− .unwrap();
2590− PollResp {
2591− events: vec![PollEvent {
2592− encrypted_blob: hex::encode(blob),
2593− }],
2594− cursor: 1,
2595− }
2596− }
2597−
2598− fn text(id: &str, body: &str) -> Payload {
2599− Payload::Text {
2600− id: id.to_string(),
2601− body: body.to_string(),
2602− }
2603− }
2604−
2605− fn queued(recipient: &str, body: &str) -> OutboxItem {
2606− OutboxItem::new(
2607− recipient.to_string(),
2608− Arc::new(protocol::serialize_payload(&text(body, body), 1)),
2609− )
2610− }
2611−
2612− /// The `/messages` batches a command actually puts on the wire. Reading them
2613− /// off the effect is the only way to see what was *sent*, as opposed to what
2614− /// the model thinks it sent.
2615− fn sent_batches(cmd: &mut Command<Effect, Event>) -> Vec<Vec<Envelope>> {
2616− #[derive(Deserialize)]
2617− struct Sent {
2618− messages: Vec<Envelope>,
2619− }
2620− cmd.effects()
2621− .filter_map(|e| match e {
2622− Effect::Http(req) if req.operation.url.ends_with("/messages") => {
2623− serde_json::from_slice::<Sent>(&req.operation.body)
2624− .ok()
2625− .map(|b| b.messages)
2626− }
2627− _ => None,
2628− })
2629− .collect()
2630− }
2631−
2632− fn ok_response(status: u16) -> crux_http::Response<Vec<u8>> {
2633− let status = crux_http::http::StatusCode::try_from(status).unwrap();
2634− crux_http::testing::ResponseBuilder::with_status(status)
2635− .body(Vec::new())
2636− .build()
2637− }
2638−
2639− /// The same, plus one header — the only thing a relay can say to us beyond a
2640− /// status code, and `Retry-After` is the one we act on.
2641− fn response_with_header(status: u16, name: &str, value: &str) -> crux_http::Response<Vec<u8>> {
2642− let status = crux_http::http::StatusCode::try_from(status).unwrap();
2643− crux_http::testing::ResponseBuilder::with_status(status)
2644− .header(name, value)
2645− .body(Vec::new())
2646− .build()
2647− }
2648−
2649− /// The delays a command actually asks the shell to wait, in milliseconds.
2650− /// Asserting on the model alone cannot see these — a backoff lives entirely in
2651− /// the effect.
2652− fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
2653− cmd.effects()
2654− .filter_map(|e| match e {
2655− Effect::Time(req) => match req.operation {
2656− crux_time::TimeRequest::NotifyAfter { duration, .. } => {
2657− Some(Duration::from(duration).as_millis() as u64)
2658− }
2659− _ => None,
2660− },
2661− _ => None,
2662− })
2663− .collect()
2664− }
2665−
2666− /// One full send round-trip: flush the outbox, then answer with `status`.
2667− fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2668− let _ = app.update(Event::StartFlush, m);
2669− let gen = m.flush_gen;
2670− let _ = app.update(Event::SendResult(gen, Ok(ok_response(status))), m);
2671− }
2672−
2673− // -----------------------------------------------------------------------
2674− // outbox: retry budget and TTL
2675− // -----------------------------------------------------------------------
2676−
2677− /// The outbox is a strict FIFO, so an item that can never be delivered used to
2678− /// block every message queued behind it — forever.
2679− #[test]
2680− fn an_undeliverable_item_is_dropped_once_it_runs_out_of_retries() {
2681− let app = Skrepka;
2682− let mut m = with_identity();
2683− m.token = Some("t".into());
2684− let stuck = peer_hex(9);
2685− let behind = peer_hex(11);
2686− m.outbox.push_back(queued(&stuck, "wedged"));
2687− m.outbox.push_back(queued(&behind, "stranded"));
2688−
2689− // The relay keeps 500-ing on the head of the queue.
2690− for i in 1..MAX_OUTBOX_RETRIES {
2691− send_and_answer(&app, &mut m, 503);
2692− assert_eq!(m.outbox.len(), 2, "still queued after {i} failure(s)");
2693− assert_eq!(m.outbox[0].retries, i, "and its budget is being spent");
2694− }
2695−
2696− // The last one exhausts the budget: the head goes, and what was stuck
2697− // behind it goes out.
2698− let _ = app.update(Event::StartFlush, &mut m);
2699− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(503))), &mut m);
2700−
2701− assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2702− assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
2703− assert_eq!(
2704− sent_batches(&mut cmd).len(),
2705− 1,
2706− "the message behind it is sent immediately, not left for the next flush"
2707− );
2708− let err = m.error.expect("the user is told the message was given up on");
2709− assert!(err.contains("gave up sending 1"), "got: {err}");
2710− }
2711−
2712− /// The counter alone is not enough: retries only accrue when a send is
2713− /// attempted, so a client that goes online once a day would take a fortnight to
2714− /// spend a ten-retry budget. The TTL bounds it in wall-clock time instead.
2715− #[test]
2716− fn an_item_older_than_the_ttl_is_dropped_on_its_next_failure() {
2717− let app = Skrepka;
2718− let mut m = with_identity();
2719− m.token = Some("t".into());
2720− let peer = peer_hex(9);
2721−
2722− let mut old = queued(&peer, "ancient");
2723− old.first_attempt = now_ms() - OUTBOX_TTL_MS - 1;
2724− old.retries = 1; // nowhere near the retry cap
2725− m.outbox.push_back(old);
2726−
2727− send_and_answer(&app, &mut m, 503);
2728−
2729− assert!(m.outbox.is_empty(), "the TTL alone is enough to drop it");
2730− assert!(m.error.is_some_and(|e| e.contains("gave up sending")));
2731− }
2732−
2733− /// An item queued during a week offline must not be dropped the moment the
2734− /// network returns: the TTL runs from the first *attempt*, not from when the
2735− /// message was written. An outbox blob from a build with no `first_attempt`
2736− /// field deserializes to 0, which must mean "never attempted", not "epoch".
2737− #[test]
2738− fn an_item_that_was_never_attempted_has_no_ttl_to_outlive() {
2739− let item = OutboxItem::new(peer_hex(9), Arc::new("{}".into()));
2740− assert_eq!(item.first_attempt, 0);
2741− assert!(
2742− !item.is_expired(now_ms()),
2743− "an unsent item must not age out against the epoch"
2744− );
2745− }
2746−
2747− /// The TTL clock has to survive a relaunch, or a permanently-stuck item is
2748− /// handed a fresh 24 hours on every launch and the outbox wedges forever again.
2749− #[test]
2750− fn the_first_attempt_stamp_is_persisted_when_the_item_goes_out() {
2751− let app = Skrepka;
2752− let mut m = with_identity();
2753− m.token = Some("t".into());
2754− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2755− let before = now_ms();
2756−
2757− let mut cmd = app.update(Event::StartFlush, &mut m);
2758−
2759− assert!(m.outbox[0].first_attempt >= before, "the item is stamped");
2760− // ...and the stamp is written to the `outbox` key, not just to memory.
2761− let wrote_outbox = cmd.effects().any(|e| {
2762− matches!(e, Effect::KeyValue(req)
2763− if matches!(&req.operation, crux_kv::KeyValueOperation::Set { key, .. } if key == K_OUTBOX))
2764− });
2765− assert!(wrote_outbox, "the stamped outbox is persisted");
2766− }
2767−
2768− /// A transient failure below the budget changes nothing but the counter — the
2769− /// message stays queued and goes out on the next flush.
2770− #[test]
2771− fn a_transient_failure_within_budget_keeps_the_message() {
2772− let app = Skrepka;
2773− let mut m = with_identity();
2774− m.token = Some("t".into());
2775− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2776−
2777− send_and_answer(&app, &mut m, 503);
2778− assert_eq!(m.outbox.len(), 1);
2779− assert_eq!(m.outbox[0].retries, 1);
2780− assert!(m.error.is_none(), "a single 5xx is not worth shouting about");
2781− assert!(!m.flushing, "and the guard is released so the next flush can run");
2782−
2783− // The relay recovers; the message goes out and the item is gone.
2784− send_and_answer(&app, &mut m, 200);
2785− assert!(m.outbox.is_empty());
2786− }
2787−
2788− /// A 401 is not a delivery failure — it means our token expired. The batch must
2789− /// not be charged a retry for it, or a long-lived client burns its whole budget
2790− /// on routine re-auths.
2791− #[test]
2792− fn a_401_does_not_spend_the_retry_budget() {
2793− let app = Skrepka;
2794− let mut m = with_identity();
2795− m.token = Some("t".into());
2796− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2797−
2798− send_and_answer(&app, &mut m, 401);
2799−
2800− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2801− assert_eq!(m.outbox[0].retries, 0, "and unpunished");
2802− assert!(m.token.is_none(), "but the token is thrown away");
2803− }
2804−
2805− /// A 429 is the relay's load, not the message's fault. Charging the batch a
2806− /// retry for it would let a busy relay drive perfectly good messages out of the
2807− /// outbox — and the relay usually says exactly how long to wait, so wait that
2808− /// long rather than guessing.
2809− #[test]
2810− fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
2811− let app = Skrepka;
2812− let mut m = with_identity();
2813− m.token = Some("t".into());
2814− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2815−
2816− let _ = app.update(Event::StartFlush, &mut m);
2817− let mut cmd = app.update(
2818− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2819− &mut m,
2820− );
2821−
2822− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2823− assert_eq!(m.outbox[0].retries, 0, "and unpunished — it did nothing wrong");
2824− assert!(!m.flushing, "the guard is released so the retry can run");
2825− assert_eq!(
2826− scheduled_delays(&mut cmd),
2827− vec![2_000],
2828− "and we wait exactly as long as the relay asked"
2829− );
2830− }
2831−
2832− /// Without the header there is nothing to honour, so fall back to our own
2833− /// backoff rather than hammering the relay that just asked us to slow down.
2834− ///
2835− /// On the *send* loop's counter. `poll_retries` is reset by every page the poll
2836− /// loop takes, so spending it here means a client that polls happily while the
2837− /// relay throttles its sends restarts the send backoff at 3 s forever.
2838− #[test]
2839− fn a_429_without_header_uses_send_side_backoff() {
2840− let app = Skrepka;
2841− let mut m = with_identity();
2842− m.token = Some("t".into());
2843− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2844− // The poll loop is healthy and has just taken a page — exactly the state that
2845− // used to reset the send backoff out from under itself.
2846− m.poll_retries = 0;
2847−
2848− let _ = app.update(Event::StartFlush, &mut m);
2849− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2850−
2851− assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2852− assert_eq!(
2853− scheduled_delays(&mut cmd),
2854− vec![backoff_ms(0)],
2855− "the first send-side backoff"
2856− );
2857− assert_eq!(m.send_retries, 1, "and the send loop's own counter is spent");
2858−
2859− // A second 429 climbs, rather than restarting at 3 s.
2860− m.flush_paused_until = 0;
2861− let _ = app.update(Event::StartFlush, &mut m);
2862− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2863− assert_eq!(scheduled_delays(&mut cmd), vec![backoff_ms(1)], "it climbs");
2864− assert_eq!(m.send_retries, 2);
2865− }
2866−
2867− /// The `Retry-After` timer is not the only thing that fires `StartFlush` — a poll
2868− /// page and a fresh `SendText` both do. Without a pause latched on the model the
2869− /// wait is decorative: the next poll page walks through it and hammers the relay
2870− /// that just asked us to stop.
2871− #[test]
2872− fn a_429_pauses_flush_until_the_delay_elapses() {
2873− let app = Skrepka;
2874− let mut m = with_identity();
2875− m.token = Some("t".into());
2876− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2877−
2878− let _ = app.update(Event::StartFlush, &mut m);
2879− let _ = app.update(
2880− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2881− &mut m,
2882− );
2883− assert!(
2884− m.flush_paused_until > now_ms(),
2885− "the relay's wait is latched on the model, not just on a timer"
2886− );
2887−
2888− // Anything else that wakes the send loop during the wait — a poll page, a new
2889− // message — must find it closed.
2890− let mut cmd = app.update(Event::StartFlush, &mut m);
2891− assert!(
2892− sent_batches(&mut cmd).is_empty(),
2893− "nothing goes out while the relay has asked us to wait"
2894− );
2895− assert!(!m.flushing, "and the loop is left free to resume later");
2896− assert_eq!(m.outbox.len(), 1, "the message is still queued");
2897−
2898− // Time passes (the clock cannot be moved in a test, so retire the pause the
2899− // way the elapsing of it would) and the timer's own `StartFlush` lands.
2900− m.flush_paused_until = 0;
2901− let mut cmd = app.update(Event::StartFlush, &mut m);
2902− assert_eq!(
2903− sent_batches(&mut cmd).len(),
2904− 1,
2905− "and once the wait is over the batch goes out"
2906− );
2907− }
2908−
2909− /// A send the relay accepts means the throttle is off. Keeping the counter would
2910− /// make the next 429 — hours later, under unrelated load — resume at whatever
2911− /// height the last one climbed to.
2912− #[test]
2913− fn a_successful_send_clears_the_send_side_backoff() {
2914− let app = Skrepka;
2915− let mut m = with_identity();
2916− m.token = Some("t".into());
2917− m.send_retries = 4;
2918− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2919−
2920− send_and_answer(&app, &mut m, 200);
2921−
2922− assert_eq!(m.send_retries, 0, "the relay is taking our sends again");
2923− }
2924−
2925− /// `Retry-After` is a number a relay chooses and we obey. Unbounded, one of
2926− /// them parks the outbox for thirty years — with nothing on screen to say why.
2927− #[test]
2928− fn an_absurd_retry_after_is_clamped() {
2929− let app = Skrepka;
2930− let mut m = with_identity();
2931− m.token = Some("t".into());
2932− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2933−
2934− let _ = app.update(Event::StartFlush, &mut m);
2935− let mut cmd = app.update(
2936− Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "999999999"))),
2937− &mut m,
2938− );
2939−
2940− assert_eq!(
2941− scheduled_delays(&mut cmd),
2942− vec![MAX_RETRY_AFTER_MS],
2943− "we wait, but only as long as we are willing to"
2944− );
2945− }
2946−
2947− // -----------------------------------------------------------------------
2948− // outbox: batching
2949− // -----------------------------------------------------------------------
2950−
2951− /// Sending one message per request meant a round-trip per *ack* too, so
2952− /// catching up on a hundred messages cost a hundred requests.
2953− #[test]
2954− fn consecutive_items_for_one_recipient_go_out_in_a_single_request() {
2955− let app = Skrepka;
2956− let mut m = with_identity();
2957− m.token = Some("t".into());
2958− let peer = peer_hex(9);
2959− for i in 0..5 {
2960− m.outbox.push_back(queued(&peer, &format!("m{i}")));
2961− }
2962−
2963− let mut cmd = app.update(Event::StartFlush, &mut m);
2964−
2965− let batches = sent_batches(&mut cmd);
2966− assert_eq!(batches.len(), 1, "one request, not five");
2967− assert_eq!(batches[0].len(), 5, "carrying every message");
2968− assert!(batches[0].iter().all(|e| e.to == peer));
2969−
2970− // ...and its success clears the whole batch, not just the head.
2971− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2972− assert!(m.outbox.is_empty());
2973− }
2974−
2975− /// The batch stops at the first recipient change: a batch is delivered as a
2976− /// unit, and hoisting a later item for the same peer over an earlier one for a
2977− /// different peer would let one busy conversation starve the rest of the queue.
2978− #[test]
2979− fn a_batch_stops_at_the_first_recipient_change() {
2980− let app = Skrepka;
2981− let mut m = with_identity();
2982− m.token = Some("t".into());
2983− let a = peer_hex(9);
2984− let b = peer_hex(11);
2985− m.outbox.push_back(queued(&a, "a1"));
2986− m.outbox.push_back(queued(&a, "a2"));
2987− m.outbox.push_back(queued(&b, "b1"));
2988− m.outbox.push_back(queued(&a, "a3"));
2989−
2990− let mut cmd = app.update(Event::StartFlush, &mut m);
2991− let batches = sent_batches(&mut cmd);
2992− assert_eq!(batches.len(), 1);
2993− assert_eq!(batches[0].len(), 2, "only the run at the head");
2994− assert!(batches[0].iter().all(|e| e.to == a));
2995−
2996− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2997− assert_eq!(m.outbox.len(), 2);
2998− let batches = sent_batches(&mut cmd);
2999− assert_eq!(batches[0].len(), 1, "then b's single message");
3000− assert_eq!(batches[0][0].to, b);
3001− }
3002−
3003− /// The relay answers `413 batch_too_large` past a hundred (PROTOCOL.md §7) —
3004− /// which we would read as a transient failure and retry forever.
3005− #[test]
3006− fn a_batch_is_capped_at_the_relays_limit() {
3007− let app = Skrepka;
3008− let mut m = with_identity();
3009− m.token = Some("t".into());
3010− let peer = peer_hex(9);
3011− for i in 0..(MAX_SEND_BATCH + 20) {
3012− m.outbox.push_back(queued(&peer, &format!("m{i}")));
3013− }
3014−
3015− let mut cmd = app.update(Event::StartFlush, &mut m);
3016− let batches = sent_batches(&mut cmd);
3017− assert_eq!(batches[0].len(), MAX_SEND_BATCH);
3018−
3019− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3020− assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
3021− }
3022−
3023− /// A hundred profile broadcasts each carrying a 64 KiB photo would build a
3024− /// request far past the body-size cap a relay has by default — and the `413`
3025− /// that came back would look transient, so we would retry it forever.
3026− #[test]
3027− fn a_batch_is_capped_by_body_size_as_well_as_count() {
3028− let app = Skrepka;
3029− let mut m = with_identity();
3030− m.token = Some("t".into());
3031− let peer = peer_hex(9);
3032− // A body at the cap, and incompressible — `crypto::encrypt` deflates the
3033− // plaintext, so a run of one character would ride out to nearly nothing and
3034− // prove no cap at all.
3035− let mut seed = 1u64;
3036− let big: String = std::iter::repeat_with(|| {
3037− seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3038− char::from(b'a' + (seed >> 33) as u8 % 26)
3039− })
3040− .take(protocol::MAX_BODY_LEN)
3041− .collect();
3042− for i in 0..MAX_SEND_BATCH {
3043− m.outbox.push_back(OutboxItem::new(
3044− peer.clone(),
3045− Arc::new(protocol::serialize_payload(&text(&format!("m{i}"), &big), 1)),
3046− ));
3047− }
3048−
3049− let mut cmd = app.update(Event::StartFlush, &mut m);
3050− let batches = sent_batches(&mut cmd);
3051− let body: usize = batches[0].iter().map(|e| e.encrypted_blob.len()).sum();
3052−
3053− assert!(batches[0].len() < MAX_SEND_BATCH, "the count cap is not the binding one");
3054− assert!(!batches[0].is_empty(), "but something still goes out");
3055− assert!(body <= MAX_SEND_BATCH_BYTES, "the body stays inside the budget");
3056− }
3057−
3058− /// `MAX_SEND_BATCH_BYTES` is a *guess* at a cap the protocol never publishes, and
3059− /// a relay configured below it answers `413`. Read as a transient failure, that
3060− /// `413` rebuilds the identical batch, is rejected identically, and repeats until
3061− /// every item has burned its retry budget — the outbox drains without a single
3062− /// message being delivered. So a `413` measures the cap instead of fighting it.
3063− #[test]
3064− fn a_413_halves_the_batch_budget_and_retries_smaller() {
3065− let app = Skrepka;
3066− let mut m = with_identity();
3067− m.token = Some("t".into());
3068− let peer = peer_hex(9);
3069− for i in 0..5 {
3070− m.outbox.push_back(queued(&peer, &format!("m{i}")));
3071− }
3072−
3073− // Nothing is known about this relay yet, so all five go out at once.
3074− let mut cmd = app.update(Event::StartFlush, &mut m);
3075− let first = sent_batches(&mut cmd);
3076− assert_eq!(first[0].len(), 5, "the whole run goes out on the first attempt");
3077− assert!(m.send_batch_budget.is_none(), "and we have assumed nothing");
3078− let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
3079−
3080− // ...and the relay says that was too big.
3081− let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(413))), &mut m);
3082−
3083− assert_eq!(
3084− m.send_batch_budget,
3085− Some(sent_bytes / 2),
3086− "the budget is halved against what actually failed, not against the constant"
3087− );
3088− assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
3089− assert!(
3090− m.outbox.iter().all(|i| i.retries == 0),
3091− "and the items are not charged for the relay being smaller than we guessed"
3092− );
3093−
3094− // The retry goes out at once, and it is smaller.
3095− let second = sent_batches(&mut cmd);
3096− assert_eq!(second.len(), 1, "retried immediately, not left for the next flush");
3097− let shrunk = second[0].len();
3098− assert!(shrunk < 5, "with a smaller batch, got {shrunk} items");
3099− assert!(shrunk > 0, "but not an empty one");
3100−
3101− // The relay takes the smaller batch, and exactly it is popped.
3102− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3103− assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
3104− assert_eq!(
3105− m.send_batch_budget,
3106− Some(sent_bytes / 2),
3107− "and what we learned about the relay survives the success — its cap did not grow"
3108− );
3109− }
3110−
3111− /// A batch of one cannot be split, and `flush_next` takes the first item whatever
3112− /// it weighs — so halving the budget around a lone oversized item would rebuild
3113− /// the same one-item batch forever, at full speed, with no retry ever charged.
3114− /// It has to fall through to the retry budget, which is what eventually drops it.
3115− #[test]
3116− fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
3117− let app = Skrepka;
3118− let mut m = with_identity();
3119− m.token = Some("t".into());
3120− m.outbox.push_back(queued(&peer_hex(9), "bigger than this relay allows"));
3121−
3122− for i in 1..MAX_OUTBOX_RETRIES {
3123− send_and_answer(&app, &mut m, 413);
3124− assert_eq!(m.outbox.len(), 1, "still queued after {i} rejection(s)");
3125− assert_eq!(m.outbox[0].retries, i, "and charged for each of them");
3126− }
3127− assert!(
3128− m.send_batch_budget.is_none(),
3129− "an unsplittable batch teaches us nothing about the cap"
3130− );
3131−
3132− send_and_answer(&app, &mut m, 413);
3133− assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
3134− assert!(
3135− m.error.is_some_and(|e| e.contains("gave up sending")),
3136− "and the user is told, rather than the message quietly vanishing"
3137− );
3138− }
3139−
3140− /// What we learned is about *that relay*. A new one is entitled to a bigger cap,
3141− /// and it will never send the `413` that would correct a budget carried over.
3142− #[test]
3143− fn changing_relay_forgets_the_learned_batch_budget() {
3144− let app = Skrepka;
3145− let mut m = with_identity();
3146− m.send_batch_budget = Some(1024);
3147−
3148− let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
3149−
3150− assert!(m.send_batch_budget.is_none());
3151− }
3152−
3153− /// An unusable key at the head still has to be discarded, batching or not —
3154− /// and the discard must not take the rest of the queue with it.
3155− #[test]
3156− fn an_unsendable_head_is_dropped_and_the_batch_behind_it_still_goes_out() {
3157− let app = Skrepka;
3158− let mut m = with_identity();
3159− m.token = Some("t".into());
3160− let good = peer_hex(9);
3161− m.outbox.push_back(queued(&hex::encode([0xabu8; 32]), "doomed"));
3162− m.outbox.push_back(queued(&good, "fine"));
3163− m.outbox.push_back(queued(&good, "also fine"));
3164−
3165− let mut cmd = app.update(Event::StartFlush, &mut m);
3166−
3167− assert_eq!(m.outbox.len(), 2, "only the unsendable item is dropped");
3168− let batches = sent_batches(&mut cmd);
3169− assert_eq!(batches[0].len(), 2, "and the batch behind it goes out whole");
3170− assert!(m.error.is_some_and(|e| e.contains("invalid key")));
3171− }
3172−
3173− // -----------------------------------------------------------------------
3174− // outbox: profile supersede
3175− // -----------------------------------------------------------------------
3176−
3177− /// A profile is state, not an event: only the newest one means anything. Five
3178− /// quick edits used to queue five payloads per contact — each with the photo.
3179− #[test]
3180− fn repeated_profile_edits_supersede_rather_than_pile_up() {
3181− let app = Skrepka;
3182− let mut m = with_identity();
3183− let a = peer_hex(9);
3184− let b = peer_hex(11);
3185− for peer in [&a, &b] {
3186− m.contacts
3187− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3188− }
3189−
3190− for name in ["One", "Two", "Three"] {
3191− let _ = app.update(
3192− Event::SaveProfile {
3193− display_name: name.into(),
3194− bio: String::new(),
3195− photo: None,
3196− },
3197− &mut m,
3198− );
3199− }
3200−
3201− assert_eq!(m.outbox.len(), 2, "one pending profile per contact, not three");
3202− for item in &m.outbox {
3203− assert!(item.is_profile());
3204− let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
3205− match parsed.payload {
3206− Payload::Profile { display_name, .. } => {
3207− assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
3208− }
3209− _ => panic!("expected a profile"),
3210− }
3211− }
3212− }
3213−
3214− /// Superseding must not reach into a payload that is already on the wire: its
3215− /// ciphertext is built and gone, and `SendResult` pops it on success — so a
3216− /// profile swapped in now would be dropped having never been sent.
3217− #[test]
3218− fn a_profile_already_in_flight_is_not_rewritten_under_the_send() {
3219− let app = Skrepka;
3220− let mut m = with_identity();
3221− m.token = Some("t".into());
3222− let peer = peer_hex(9);
3223− m.contacts
3224− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3225−
3226− let _ = app.update(
3227− Event::SaveProfile {
3228− display_name: "Old".into(),
3229− bio: String::new(),
3230− photo: None,
3231− },
3232− &mut m,
3233− );
3234− // It goes out and is still in flight.
3235− let _ = app.update(Event::StartFlush, &mut m);
3236− assert!(m.flushing);
3237−
3238− // A second edit lands mid-send. It must queue a *new* item rather than
3239− // rewrite the one whose ciphertext is already gone.
3240− let _ = app.update(
3241− Event::SaveProfile {
3242− display_name: "New".into(),
3243− bio: String::new(),
3244− photo: None,
3245− },
3246− &mut m,
3247− );
3248− assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
3249−
3250− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3251− assert_eq!(m.outbox.len(), 1, "the sent one is popped");
3252− let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
3253− match parsed.payload {
3254− Payload::Profile { display_name, .. } => assert_eq!(
3255− display_name, Some("New".to_string()),
3256− "and the newer profile survives to be sent"
3257− ),
3258− _ => panic!("expected a profile"),
3259− }
3260− }
3261−
3262− // -----------------------------------------------------------------------
3263− // in-flight bookkeeping
3264− // -----------------------------------------------------------------------
3265−
3266− /// `SendResult` pops by count off the head of the outbox. If the items it sent
3267− /// were removed from under it — the user blocked the peer mid-send — popping by
3268− /// count anyway takes whatever slid into their place: another peer's mail.
3269− #[test]
3270− fn blocking_the_peer_mid_send_does_not_pop_someone_elses_messages() {
3271− let app = Skrepka;
3272− let mut m = with_identity();
3273− m.token = Some("t".into());
3274− let enemy = peer_hex(9);
3275− let friend = peer_hex(11);
3276− m.contacts
3277− .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3278− m.outbox.push_back(queued(&enemy, "e1"));
3279− m.outbox.push_back(queued(&enemy, "e2"));
3280− m.outbox.push_back(queued(&friend, "f1"));
3281−
3282− let _ = app.update(Event::StartFlush, &mut m);
3283− assert_eq!(m.in_flight.as_ref().unwrap().count, 2);
3284−
3285− // Blocked mid-flight: the two items in the air are dropped from the queue.
3286− let _ = app.update(
3287− Event::SetBlocked {
3288− peer: enemy.clone(),
3289− blocked: true,
3290− },
3291− &mut m,
3292− );
3293− assert_eq!(m.outbox.len(), 1);
3294−
3295− // The send lands. It must pop nothing.
3296− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3297− assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
3298− assert_eq!(m.outbox[0].recipient, friend);
3299− }
3300−
3301− // -----------------------------------------------------------------------
3302− // delete contact
3303− // -----------------------------------------------------------------------
3304−
3305− /// Blocking only silences a peer; the entry stayed in the list forever.
3306− #[test]
3307− fn deleting_a_contact_forgets_the_contact_the_history_and_the_queue() {
3308− let app = Skrepka;
3309− let mut m = with_identity();
3310− let peer = peer_hex(9);
3311− let other = peer_hex(11);
3312− for p in [&peer, &other] {
3313− m.contacts
3314− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3315− m.messages.insert(
3316− p.clone(),
3317− vec![StoredMessage {
3318− id: "m1".into(),
3319− body: "hi".into(),
3320− ts: 1,
3321− outgoing: false,
3322− delivered: false,
3323− }],
3324− );
3325− m.outbox.push_back(queued(p, "queued"));
3326− }
3327− m.active_peer = Some(peer.clone());
3328− m.page = Page::Chat;
3329−
3330− let mut cmd = app.update(Event::DeleteContact { peer: peer.clone() }, &mut m);
3331−
3332− assert!(!m.contacts.contains_key(&peer));
3333− assert!(!m.messages.contains_key(&peer));
3334− assert!(
3335− m.outbox.iter().all(|i| i.recipient != peer),
3336− "nothing more goes to a peer we have forgotten"
3337− );
3338− assert_eq!(m.page, Page::Conversations, "the open chat is closed");
3339− assert!(m.active_peer.is_none());
3340−
3341− // Everyone else is untouched.
3342− assert!(m.contacts.contains_key(&other));
3343− assert_eq!(m.messages[&other].len(), 1);
3344− assert_eq!(m.outbox.len(), 1);
3345−
3346− // The history blob is *deleted*, not left behind holding what the user just
3347− // asked us to forget.
3348− let deleted = cmd.effects().any(|e| {
3349− matches!(e, Effect::KeyValue(req)
3350− if matches!(&req.operation, crux_kv::KeyValueOperation::Delete { key } if key == &k_messages(&peer)))
3351− });
3352− assert!(deleted, "the messages:<peer> key is removed");
3353− }
3354−
3355− /// Deleting a peer we are not looking at must not throw the user out of the
3356− /// chat they *are* looking at.
3357− #[test]
3358− fn deleting_another_contact_leaves_the_open_chat_alone() {
3359− let app = Skrepka;
3360− let mut m = with_identity();
3361− let open = peer_hex(9);
3362− let doomed = peer_hex(11);
3363− for p in [&open, &doomed] {
3364− m.contacts
3365− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3366− }
3367− m.active_peer = Some(open.clone());
3368− m.page = Page::Chat;
3369−
3370− let _ = app.update(Event::DeleteContact { peer: doomed }, &mut m);
3371−
3372− assert_eq!(m.page, Page::Chat);
3373− assert_eq!(m.active_peer.as_deref(), Some(open.as_str()));
3374− }
3375−
3376− // -----------------------------------------------------------------------
3377− // watermark clamping
3378− // -----------------------------------------------------------------------
3379−
3380− /// An ack at `ts = i64::MAX` used to park `last_ack_ts` a minute into the
3381− /// future — and every honest ack that followed, the ones that actually mark our
3382− /// messages delivered, read as stale and was dropped. A peer could silence its
3383− /// own delivery receipts forever with one payload.
3384− #[test]
3385− fn a_far_future_ack_cannot_suppress_the_acks_that_follow_it() {
3386− let app = Skrepka;
3387− let mut m = with_identity();
3388− let peer = peer_hex(9);
3389− m.contacts
3390− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3391− m.messages.insert(
3392− peer.clone(),
3393− vec![StoredMessage {
3394− id: "m1".into(),
3395− body: "hi".into(),
3396− ts: 1,
3397− outgoing: true,
3398− delivered: false,
3399− }],
3400− );
3401−
3402− // The poisoned ack: acks nothing we sent, but sets the watermark.
3403− let ack = Payload::DeliveryAck {
3404− ack_ids: vec!["nothing".into()],
3405− };
3406− let page = page_from(9, &m, &ack, i64::MAX);
3407− let _ = app.ingest_poll(&mut m, page);
3408− assert!(
3409− m.contacts[&peer].last_ack_ts <= now_ms(),
3410− "the watermark cannot be pushed into the future"
3411− );
3412−
3413− // A perfectly ordinary ack, sent now. It must still land.
3414− let ack = Payload::DeliveryAck {
3415− ack_ids: vec!["m1".into()],
3416− };
3417− let page = page_from(9, &m, &ack, now_ms());
3418− let _ = app.ingest_poll(&mut m, page);
3419− assert!(
3420− m.messages[&peer][0].delivered,
3421− "an honest ack after a far-future one must still be honoured"
3422− );
3423− }
3424−
3425− /// The same trick against `last_profile_ts`: a profile at `ts = i64::MAX` would
3426− /// freeze the peer's own entry, since every honest update they sent afterwards
3427− /// would read as a stale replay of itself.
3428− #[test]
3429− fn a_far_future_profile_cannot_freeze_the_peers_own_profile() {
3430− let app = Skrepka;
3431− let mut m = with_identity();
3432− let peer = peer_hex(9);
3433− m.contacts
3434− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3435−
3436− let profile = |name: &str| Payload::Profile {
3437− display_name: Some(name.to_string()),
3438− bio: Some(String::new()),
3439− photo: None,
3440− };
3441− let page = page_from(9, &m, &profile("Far"), i64::MAX);
3442− let _ = app.ingest_poll(&mut m, page);
3443− let first_ts = m.contacts[&peer].last_profile_ts;
3444− assert!(first_ts <= now_ms());
3445−
3446− // A later profile at a greater ts must still apply — the clamping
3447− // is what stops the far-future one from freezing the peer.
3448− let page = page_from(9, &m, &profile("Now"), first_ts + 1);
3449− let _ = app.ingest_poll(&mut m, page);
3450− assert_eq!(
3451− m.contacts[&peer].display_name, "Now",
3452− "a later, honest profile must still apply"
3453− );
3454− }
3455−
3456− // -----------------------------------------------------------------------
3457− // conversation ordering
3458− // -----------------------------------------------------------------------
3459−
3460− /// The conversation is held in `ts` order rather than re-sorted per arrival.
3461− /// `trim_history` ages out the *front*, so an out-of-order insert does not just
3462− /// render wrong — it throws away the wrong message.
3463− #[test]
3464− fn messages_are_inserted_in_timestamp_order() {
3465− let app = Skrepka;
3466− let mut m = with_identity();
3467− let peer = peer_hex(9);
3468−
3469− // Arrive out of order, including a tie.
3470− for (i, ts) in [300i64, 100, 200, 200].into_iter().enumerate() {
3471− let page = page_from(9, &m, &text(&format!("m{i}"), "x"), ts);
3472− let _ = app.ingest_poll(&mut m, page);
3473− }
3474− // ...and an outgoing message, which used to be appended blindly.
3475− m.active_peer = Some(peer.clone());
3476− m.compose = "mine".into();
3477− let _ = app.update(Event::SendText, &mut m);
3478−
3479− let convo = &m.messages[&peer];
3480− assert!(
3481− convo.windows(2).all(|w| w[0].ts <= w[1].ts),
3482− "the conversation is sorted: {:?}",
3483− convo.iter().map(|s| s.ts).collect::<Vec<_>>()
3484− );
3485− // Ties keep arrival order, exactly as the stable sort used to give.
3486− let tied: Vec<&str> = convo
3487− .iter()
3488− .filter(|s| s.ts == 200)
3489− .map(|s| s.id.as_str())
3490− .collect();
3491− assert_eq!(tied, vec!["m2", "m3"]);
3492− }
3493−
3494− /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
3495− /// would tell the blocked peer we are online and reading.
3496− #[test]
3497− fn a_blocked_senders_message_is_neither_stored_nor_acked() {
3498− let app = Skrepka;
3499− let mut m = with_identity();
3500− let peer = peer_hex(9);
3501− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3502− contact.blocked = true;
3503− m.contacts.insert(peer.clone(), contact);
3504−
3505− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3506− let _ = app.ingest_poll(&mut m, page);
3507−
3508− assert!(
3509− m.messages.get(&peer).is_none_or(Vec::is_empty),
3510− "a blocked peer's message must not be stored"
3511− );
3512− assert!(m.outbox.is_empty(), "no delivery.ack goes back to a blocked peer");
3513− // ...and nothing to render in the chat, even if it is opened.
3514− m.active_peer = Some(peer);
3515− m.page = Page::Chat;
3516− assert!(app.view(&m).messages.is_empty());
3517− }
3518−
3519− /// The same page, unblocked: the control that proves the gate is the block
3520− /// flag and not something else in the ingest path.
3521− fn ingest_one_text_from_an_unblocked_peer() -> (Model, String) {
3522− let app = Skrepka;
3523− let mut m = with_identity();
3524− let peer = peer_hex(9);
3525− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3526− let _ = app.ingest_poll(&mut m, page);
3527− (m, peer)
3528− }
3529−
3530− #[test]
3531− fn an_unblocked_senders_message_is_stored_and_acked() {
3532− let (m, peer) = ingest_one_text_from_an_unblocked_peer();
3533− assert_eq!(m.messages[&peer].len(), 1);
3534− assert_eq!(m.messages[&peer][0].body, "hi");
3535− // D6-app: A profile broadcast is queued alongside the delivery.ack
3536− // so the new contact sees our display name and avatar.
3537− assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
3538− assert_eq!(m.outbox[0].recipient, peer);
3539− assert_eq!(m.outbox[1].recipient, peer);
3540− }
3541−
3542− /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
3543− /// conversation list forever, and render with an absurd timestamp.
3544− #[test]
3545− fn a_far_future_timestamp_is_clamped_to_now() {
3546− let app = Skrepka;
3547− let mut m = with_identity();
3548− let peer = peer_hex(9);
3549−
3550− let page = page_from(9, &m, &text("m1", "hi"), i64::MAX);
3551− let before = now_ms();
3552− let _ = app.ingest_poll(&mut m, page);
3553−
3554− let ts = m.messages[&peer][0].ts;
3555− assert!(
3556− ts <= now_ms() + MAX_FUTURE_SKEW_MS,
3557− "ts {ts} must be clamped into the skew window"
3558− );
3559− assert!(ts >= before, "and clamped to now, not to some past value");
3560− // The clamped value is what the shell sorts and renders by.
3561− assert_eq!(app.view(&m).contacts[0].last_ts, ts);
3562− }
3563−
3564− /// Honest clock skew inside the tolerance is preserved as-is.
3565− #[test]
3566− fn a_slightly_future_timestamp_is_kept() {
3567− let app = Skrepka;
3568− let mut m = with_identity();
3569− let peer = peer_hex(9);
3570− let ts = now_ms() + 5_000;
3571−
3572− let page = page_from(9, &m, &text("m1", "hi"), ts);
3573− let _ = app.ingest_poll(&mut m, page);
3574−
3575− assert_eq!(m.messages[&peer][0].ts, ts);
3576− }
3577−
3578− #[test]
3579− fn message_history_is_capped_at_the_cutoff() {
3580− let app = Skrepka;
3581− let mut m = with_identity();
3582− let peer = peer_hex(9);
3583−
3584− let convo = m.messages.entry(peer.clone()).or_default();
3585− for i in 0..MAX_MESSAGES_PER_PEER {
3586− convo.push(StoredMessage {
3587− id: format!("old-{i}"),
3588− body: "x".into(),
3589− ts: i as i64,
3590− outgoing: false,
3591− delivered: false,
3592− });
3593− }
3594−
3595− let page = page_from(9, &m, &text("new", "newest"), now_ms());
3596− let _ = app.ingest_poll(&mut m, page);
3597−
3598− let convo = &m.messages[&peer];
3599− assert_eq!(convo.len(), MAX_MESSAGES_PER_PEER, "the cap holds");
3600− assert_eq!(convo.last().unwrap().id, "new", "the newest is kept");
3601− assert_eq!(convo.first().unwrap().id, "old-1", "the oldest is aged out");
3602− }
3603−
3604− /// An off-curve key can't be encrypted to, so the send is dropped — but the
3605− /// user has to be told, rather than watching the message vanish.
3606− #[test]
3607− fn a_send_to_an_unusable_key_reports_an_error() {
3608− let app = Skrepka;
3609− let mut m = with_identity();
3610− m.token = Some("t".into());
3611− let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
3612− m.outbox
3613− .push_back(OutboxItem::new(bad, Arc::new("{}".into())));
3614−
3615− let _ = app.update(Event::StartFlush, &mut m);
3616−
3617− assert!(m.outbox.is_empty(), "the unsendable item does not wedge the outbox");
3618− assert!(!m.flushing);
3619− let err = m.error.expect("the failure is surfaced");
3620− assert!(err.contains("invalid key"), "got: {err}");
3621− }
3622−
3623− #[test]
3624− fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
3625− let app = Skrepka;
3626− let mut m = with_identity();
3627− let bad = hex::encode([0xabu8; 32]);
3628−
3629− let _ = app.update(
3630− Event::AddContact {
3631− input: bad.clone(),
3632− nickname: "Mallory".into(),
3633− },
3634− &mut m,
3635− );
3636−
3637− assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
3638− assert!(m.error.is_some());
3639− }
3640−
3641− /// A dropped kv write silently loses data; it must not be swallowed.
3642− #[test]
3643− fn a_failed_save_surfaces_an_error() {
3644− let app = Skrepka;
3645− let mut m = with_identity();
3646−
3647− let _ = app.update(
3648− Event::Saved(Err(crux_kv::error::KeyValueError::Io {
3649− message: "no space left on device".into(),
3650− })),
3651− &mut m,
3652− );
3653− assert!(m.error.is_some(), "a failed write is reported");
3654−
3655− // A successful write stays quiet.
3656− m.error = None;
3657− let _ = app.update(Event::Saved(Ok(None)), &mut m);
3658− assert!(m.error.is_none());
3659− }
3660−
3661− #[test]
3662− fn server_host_strips_scheme_port_and_case() {
3663− assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
3664− assert_eq!(server_host("http://localhost:8080"), "localhost");
3665− assert_eq!(server_host("https://relay.example.com./x"), "relay.example.com");
3666− // Its only caller passes `settings.server_url`, which is always an
3667− // absolute URL (`normalize_server_url` guarantees it). Anything else is a
3668− // bug, and an empty host produces a signature the relay rejects — which is
3669− // the right failure: loud, not a silently-wrong host binding.
3670− assert_eq!(server_host("relay.example.com"), "");
3671− }
3672−
3673− /// The auth signature is bound to the host (PROTOCOL.md §6), so `server_host`
3674− /// has to agree with what the relay computes. Splitting on `:` by hand cut an
3675− /// IPv6 literal at its first colon and produced `[`.
3676− #[test]
3677− fn server_host_handles_an_ipv6_literal() {
3678− assert_eq!(server_host("http://[::1]:8080"), "[::1]");
3679− assert_eq!(
3680− server_host("https://[2001:DB8::1]/poll"),
3681− "[2001:db8::1]"
3682− );
3683− }
3684−
3685− #[test]
3686− fn backoff_is_capped() {
3687− assert_eq!(backoff_ms(0), 3000);
3688− assert_eq!(backoff_ms(1), 6000);
3689− assert_eq!(backoff_ms(3), 24000);
3690− assert_eq!(backoff_ms(10), 30000);
3691− }
3692−
3693− #[test]
3694− fn add_contact_accepts_hex_and_rejects_self() {
3695− let app = Skrepka;
3696− let mut m = with_identity();
3697− let peer = peer_hex(9);
3698− let _ = app.update(
3699− Event::AddContact {
3700− input: peer.clone(),
3701− nickname: "Bob".into(),
3702− },
3703− &mut m,
3704− );
3705− assert!(m.contacts.contains_key(&peer));
3706− assert_eq!(m.contacts[&peer].nickname, "Bob");
3707−
3708− // adding own key is rejected with an error
3709− let mine = m.my_pubkey.clone();
3710− let _ = app.update(
3711− Event::AddContact {
3712− input: mine,
3713− nickname: String::new(),
3714− },
3715− &mut m,
3716− );
3717− assert!(m.error.is_some());
3718− }
3719−
3720− /// A stale error from a previous failed add is cleared on the next attempt,
3721− /// so the AddContact form does not show an old error after the user fixes
3722− /// the input and tries again.
3723− #[test]
3724− fn add_contact_clears_a_stale_error_on_success() {
3725− let app = Skrepka;
3726− let mut m = with_identity();
3727− m.error = Some("invalid public key".into());
3728−
3729− let peer = peer_hex(9);
3730− let _ = app.update(
3731− Event::AddContact {
3732− input: peer.clone(),
3733− nickname: "Bob".into(),
3734− },
3735− &mut m,
3736− );
3737−
3738− assert!(m.error.is_none(), "a successful add clears the stale error");
3739− assert!(m.contacts.contains_key(&peer));
3740− }
3741−
3742− #[test]
3743− fn send_text_enqueues_message_profile_and_outbox() {
3744− let app = Skrepka;
3745− let mut m = with_identity();
3746− let peer = peer_hex(9);
3747− m.active_peer = Some(peer.clone());
3748− m.compose = "hello".into();
3749−
3750− let _ = app.update(Event::SendText, &mut m);
3751−
3752− // one outgoing message recorded
3753− let convo = &m.messages[&peer];
3754− assert_eq!(convo.len(), 1);
3755− assert!(convo[0].outgoing);
3756− assert_eq!(convo[0].body, "hello");
3757− // compose cleared, contact auto-created
3758− assert!(m.compose.is_empty());
3759− assert!(m.contacts.contains_key(&peer));
3760− // outbox has the text + a first-contact profile
3761− assert_eq!(m.outbox.len(), 2);
3762− assert_eq!(m.outbox[0].recipient, peer);
3763− }
3764−
3765− #[test]
3766− fn send_text_without_peer_or_empty_is_noop() {
3767− let app = Skrepka;
3768− let mut m = with_identity();
3769− m.compose = "x".into();
3770− let _ = app.update(Event::SendText, &mut m); // no active peer
3771− assert!(m.outbox.is_empty());
3772−
3773− m.active_peer = Some(peer_hex(9));
3774− m.compose = " ".into();
3775− let _ = app.update(Event::SendText, &mut m); // whitespace only
3776− assert!(m.outbox.is_empty());
3777− }
3778−
3779− /// The contact cap must be checked *before* mutating anything. The old
3780− /// order cleared `compose` and inserted the message into `model.messages`
3781− /// first, then hit the cap and returned early — losing the user's text and
3782− /// leaving an invisible orphan conversation that was never persisted.
3783− #[test]
3784− fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3785− let app = Skrepka;
3786− let mut m = with_identity();
3787− // Fill the contact list to the cap.
3788− for i in 0..MAX_CONTACTS {
3789− let k = format!("{i:064x}");
3790− m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3791− }
3792− let new_peer = peer_hex(9);
3793− m.active_peer = Some(new_peer.clone());
3794− m.compose = "hello new friend".into();
3795−
3796− let _ = app.update(Event::SendText, &mut m);
3797−
3798− assert_eq!(
3799− m.compose, "hello new friend",
3800− "compose must not be cleared on rejection"
3801− );
3802− assert!(
3803− m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3804− "no orphan conversation must be created"
3805− );
3806− assert!(m.outbox.is_empty(), "nothing must be queued");
3807− assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3808− assert!(
3809− m.error.as_ref().is_some_and(|e| e.contains("full")),
3810− "the user is told why"
3811− );
3812− }
3813−
3814− #[test]
3815− fn set_blocked_toggles_and_view_reflects_it() {
3816− let app = Skrepka;
3817− let mut m = with_identity();
3818− let peer = peer_hex(9);
3819− m.contacts
3820− .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
3821− let _ = app.update(
3822− Event::SetBlocked {
3823− peer: peer.clone(),
3824− blocked: true,
3825− },
3826− &mut m,
3827− );
3828− assert!(m.contacts[&peer].blocked);
3829− let vm = app.view(&m);
3830− assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
3831− }
3832−
3833− /// Unblocking clears a stale "cannot send to a blocked contact" error, so the
3834− /// chat error display does not persist after the user lifts the block.
3835− #[test]
3836− fn unblocking_clears_the_stale_send_error() {
3837− let app = Skrepka;
3838− let mut m = with_identity();
3839− let peer = peer_hex(9);
3840− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3841− contact.blocked = true;
3842− m.contacts.insert(peer.clone(), contact);
3843− m.error = Some("cannot send to a blocked contact".into());
3844−
3845− let _ = app.update(
3846− Event::SetBlocked {
3847− peer: peer.clone(),
3848− blocked: false,
3849− },
3850− &mut m,
3851− );
3852−
3853− assert!(!m.contacts[&peer].blocked);
3854− assert!(m.error.is_none(), "unblocking clears the stale error");
3855− }
3856−
3857− /// Gating ingest stops the acks we have not queued yet — but the ack for the
3858− /// message that made the user hit "block" is already in the outbox, and an ack
3859− /// is exactly the "we are online and reading you" signal the block exists to
3860− /// cut off.
3861− #[test]
3862− fn blocking_a_peer_drops_what_is_already_queued_for_them() {
3863− let app = Skrepka;
3864− let mut m = with_identity();
3865− let enemy = peer_hex(9);
3866− let friend = peer_hex(11);
3867− m.contacts
3868− .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3869−
3870− // An ack to the peer we are about to block, plus unrelated mail.
3871− for recipient in [&enemy, &friend] {
3872− m.outbox.push_back(OutboxItem::new(
3873− recipient.clone(),
3874− Arc::new(protocol::serialize_payload(
3875− &Payload::DeliveryAck {
3876− ack_ids: vec!["m1".into()],
3877− },
3878− 1,
3879− )),
3880− ));
3881− }
3882−
3883− let _ = app.update(
3884− Event::SetBlocked {
3885− peer: enemy.clone(),
3886− blocked: true,
3887− },
3888− &mut m,
3889− );
3890−
3891− let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
3892− assert_eq!(
3893− recipients,
3894− vec![friend.as_str()],
3895− "nothing more goes to a blocked peer — and everyone else is untouched"
3896− );
3897− }
3898−
3899− #[test]
3900− fn normalize_server_url_requires_an_http_scheme() {
3901− // The real parser canonicalizes: scheme and host are lowercased.
3902− assert_eq!(
3903− normalize_server_url(" https://Relay.Example.com/ "),
3904− Some("https://relay.example.com".to_string())
3905− );
3906− assert_eq!(
3907− normalize_server_url("HTTP://localhost:8080///"),
3908− Some("http://localhost:8080".to_string())
3909− );
3910− assert_eq!(
3911− normalize_server_url("http://[::1]:8080"),
3912− Some("http://[::1]:8080".to_string())
3913− );
3914− // The cases that used to panic Http::post or produce a dead URL.
3915− assert_eq!(normalize_server_url("relay.example.com"), None);
3916− assert_eq!(normalize_server_url("localhost:8080"), None);
3917− assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
3918− assert_eq!(normalize_server_url("http://"), None);
3919− assert_eq!(normalize_server_url("http://:8080"), None);
3920− assert_eq!(normalize_server_url("https://relay example.com"), None);
3921− assert_eq!(normalize_server_url(""), None);
3922− }
3923−
3924− /// The hand-rolled validator this replaces was not the parser it guarded:
3925− /// it waved these through, and `crux_http` unwraps the parse.
3926− #[test]
3927− fn normalize_server_url_rejects_what_the_url_parser_rejects() {
3928− assert_eq!(normalize_server_url("http://[bad"), None);
3929− assert_eq!(normalize_server_url("http://ho^st"), None);
3930− assert_eq!(normalize_server_url("http://a\u{7f}b"), None);
3931− }
3932−
3933− /// Every request appends a path, so a query or fragment would silently
3934− /// swallow it: `http://x?y` + "/poll" is `http://x?y/poll` — a request to `/`
3935− /// with a junk query, not to the poll endpoint.
3936− #[test]
3937− fn normalize_server_url_rejects_a_query_or_fragment() {
3938− assert_eq!(normalize_server_url("http://x?y"), None);
3939− assert_eq!(normalize_server_url("https://relay.example.com/#frag"), None);
3940− }
3941−
3942− #[test]
3943− fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
3944− let app = Skrepka;
3945− let mut m = with_identity();
3946− let original = m.settings.server_url.clone();
3947−
3948− let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
3949− assert_eq!(m.settings.server_url, original, "bad URL must not persist");
3950− assert!(m.error.is_some());
3951−
3952− let _ = app.update(
3953− Event::SetServerUrl(" https://relay.example.com/ ".into()),
3954− &mut m,
3955− );
3956− assert_eq!(m.settings.server_url, "https://relay.example.com");
3957− assert!(m.error.is_none());
3958− assert!(m.token.is_none());
3959− assert_eq!(m.conn, ConnStatus::Offline);
3960− }
3961−
3962− /// The regression that stalled the outbox: `flushing` stayed `true` forever
3963− /// after the first successful send, so nothing was ever sent again.
3964− #[test]
3965− fn send_result_ok_clears_flushing() {
3966− let app = Skrepka;
3967− let mut m = with_identity();
3968− m.token = Some("t".into());
3969− m.outbox
3970− .push_back(OutboxItem::new(peer_hex(9), Arc::new("{}".into())));
3971−
3972− let _ = app.update(Event::StartFlush, &mut m);
3973− assert!(m.flushing);
3974−
3975− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3976− let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3977−
3978− assert!(!m.flushing, "flushing must be cleared on the success path");
3979− assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
3980− }
3981−
3982− #[test]
3983− fn flush_next_resumes_after_a_successful_send() {
3984− let app = Skrepka;
3985− let mut m = with_identity();
3986− m.token = Some("t".into());
3987− // Two different peers, or the batching would send both at once.
3988− for seed in [9u8, 11] {
3989− m.outbox
3990− .push_back(OutboxItem::new(peer_hex(seed), Arc::new("{}".into())));
3991− }
3992− // First send goes out and marks the model as flushing.
3993− let _ = app.update(Event::StartFlush, &mut m);
3994− assert!(m.flushing);
3995−
3996− // Its success must both drop the item and let the *next* one go out.
3997− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3998− let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3999− assert_eq!(m.outbox.len(), 1);
4000− assert!(m.flushing, "the second item is now in flight");
4001− }
4002−
4003− #[test]
4004− fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
4005− let app = Skrepka;
4006− let mut m = with_identity();
4007− m.token = Some("t".into());
4008−
4009− let _ = app.update(Event::Poll, &mut m);
4010− assert!(m.polling, "the first Poll issues a request");
4011− let gen = m.poll_gen;
4012−
4013− // A second Poll (e.g. from a re-auth) must not stack another loop.
4014− let _ = app.update(Event::Poll, &mut m);
4015− assert!(m.polling);
4016− assert_eq!(m.poll_gen, gen, "and does not open a new generation");
4017−
4018− // Both success and failure release the guard so the loop can continue.
4019− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4020− let _ = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4021− assert!(!m.polling);
4022−
4023− let _ = app.update(Event::Poll, &mut m);
4024− assert!(m.polling);
4025− let _ = app.update(
4026− Event::PollResult(m.poll_gen, Err(crux_http::HttpError::Timeout)),
4027− &mut m,
4028− );
4029− assert!(!m.polling);
4030− }
4031−
4032− /// The cursor is the *relay's* sequence number, and the next poll acks
4033− /// everything up to it. Carrying relay A's cursor over to relay B tells B to
4034− /// delete every message waiting for us before handing any of them over.
4035− #[test]
4036− fn switching_relays_resets_the_cursor() {
4037− let app = Skrepka;
4038− let mut m = with_identity();
4039− m.cursor = now_ms();
4040− m.token = Some("t".into());
4041− m.conn = ConnStatus::Online;
4042−
4043− let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4044−
4045− assert_eq!(m.cursor, 0, "the new relay is polled from the beginning");
4046− assert!(m.token.is_none(), "the old relay's token is worthless");
4047− assert_eq!(m.conn, ConnStatus::Offline);
4048− }
4049−
4050− /// Resetting the cursor is not enough on its own: the poll already in flight
4051− /// is addressed to the *old* relay and its page carries the old relay's
4052− /// sequence number. Letting it land writes that number straight back over the
4053− /// reset, and the next poll hands it to the new relay — which reads it as an
4054− /// ack and drops every message queued for us before serving any of them.
4055− #[test]
4056− fn switching_relays_retires_the_poll_still_in_flight_at_the_old_one() {
4057− let app = Skrepka;
4058− let mut m = with_identity();
4059− m.token = Some("t".into());
4060− m.conn = ConnStatus::Online;
4061−
4062− let _ = app.update(Event::Poll, &mut m);
4063− let old_relay_poll = m.poll_gen;
4064− assert!(m.polling);
4065−
4066− let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4067− assert!(!m.polling, "the old relay's poll no longer holds the guard");
4068−
4069− // It resolves after the switch, carrying the old relay's cursor.
4070− let body = serde_json::to_vec(&serde_json::json!({
4071− "events": [],
4072− "cursor": 1_700_000_000_000i64,
4073− }))
4074− .unwrap();
4075− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4076− let _ = app.update(Event::PollResult(old_relay_poll, Ok(resp)), &mut m);
4077−
4078− assert_eq!(
4079− m.cursor, 0,
4080− "the new relay must not be handed a cursor from a sequence it never issued"
4081− );
4082− }
4083−
4084− /// A URL that the parser rejects reaches `Http::post`, which unwraps it. On
4085− /// disk it is a panic on every launch — a boot loop with no way out.
4086− #[test]
4087− fn a_corrupt_persisted_server_url_falls_back_to_the_default() {
4088− let app = Skrepka;
4089− let mut m = with_identity();
4090− m.loads_pending = STARTUP_LOADS;
4091−
4092− let junk = serde_json::to_vec(&serde_json::json!({"server_url": "http://[bad"})).unwrap();
4093− let _ = app.update(Event::LoadedSettings(Ok(Some(junk))), &mut m);
4094−
4095− assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4096− assert!(
4097− normalize_server_url(&m.settings.server_url).is_some(),
4098− "whatever we end up with must survive the parser"
4099− );
4100− }
4101−
4102− /// A settings blob written before a field existed must still load — otherwise
4103− /// `parse_kv` returns None, the default is used, and the next write destroys
4104− /// what was there.
4105− #[test]
4106− fn an_old_settings_blob_still_loads() {
4107− let app = Skrepka;
4108− let mut m = with_identity();
4109− m.loads_pending = STARTUP_LOADS;
4110−
4111− let empty = serde_json::to_vec(&serde_json::json!({})).unwrap();
4112− let _ = app.update(Event::LoadedSettings(Ok(Some(empty))), &mut m);
4113− assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4114−
4115− // ...and a Contact missing every optional field still becomes a contact.
4116− let peer = peer_hex(9);
4117− let old = serde_json::to_vec(&serde_json::json!([{"pubkey": peer}])).unwrap();
4118− let _ = app.update(Event::LoadedContacts(Ok(Some(old))), &mut m);
4119− assert!(m.contacts.contains_key(&peer));
4120− }
4121−
4122− /// Connecting before the startup loads land lets a poll be ingested against a
4123− /// half-loaded model — and the loads still in flight then overwrite it.
4124− #[test]
4125− fn connect_waits_for_every_startup_load() {
4126− let app = Skrepka;
4127− let mut m = with_identity();
4128− m.loads_pending = STARTUP_LOADS;
4129−
4130− let connects = |cmd: &mut Command<Effect, Event>| {
4131− cmd.events()
4132− .filter(|e| matches!(e, Event::Connect))
4133− .count()
4134− };
4135−
4136− for (i, ev) in [
4137− Event::LoadedSettings(Ok(None)),
4138− Event::LoadedProfile(Ok(None)),
4139− Event::LoadedContacts(Ok(None)),
4140− Event::LoadedCursor(Ok(None)),
4141− Event::LoadedSeenIds(Ok(None)),
4142− ]
4143− .into_iter()
4144− .enumerate()
4145− {
4146− let mut cmd = app.update(ev, &mut m);
4147− assert_eq!(
4148− m.loads_pending,
4149− STARTUP_LOADS - 1 - i as u8,
4150− "each load ticks the counter down"
4151− );
4152− assert_eq!(connects(&mut cmd), 0, "and none of them connects");
4153− }
4154−
4155− // The sixth — and only the sixth — emits Connect.
4156− let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4157− assert_eq!(m.loads_pending, 0);
4158− assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
4159−
4160− // A late duplicate (a re-delivered load) must not connect again.
4161− let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4162− assert_eq!(connects(&mut cmd), 0);
4163− }
4164−
4165− /// The sharpest edge of a failed read, and the one that costs the user real
4166− /// messages: `ingest_poll` persists the outbox on *every* poll page, including
4167− /// an empty one. So a single unreadable outbox — a background wake with the
4168− /// device locked — followed by one idle poll used to write `[]` over a queue of
4169− /// unsent mail. The client destroys the user's messages by doing nothing at all.
4170− ///
4171− /// A read failure is not an empty key. Nothing may be written until a launch
4172− /// reads the key successfully.
4173− #[test]
4174− fn a_failed_read_never_writes_over_what_it_could_not_read() {
4175− let app = Skrepka;
4176− let mut m = with_identity();
4177− m.loads_pending = STARTUP_LOADS;
4178−
4179− let connects = |cmd: &mut Command<Effect, Event>| {
4180− cmd.events()
4181− .filter(|e| matches!(e, Event::Connect))
4182− .count()
4183− };
4184− // Every key a command actually writes. The model's own view of what it saved
4185− // cannot see these — the write lives entirely in the effect.
4186− let kv_writes = |cmd: &mut Command<Effect, Event>| {
4187− cmd.effects()
4188− .filter_map(|e| match e {
4189− Effect::KeyValue(req) => match &req.operation {
4190− crux_kv::KeyValueOperation::Set { key, .. } => Some(key.clone()),
4191− _ => None,
4192− },
4193− _ => None,
4194− })
4195− .collect::<Vec<_>>()
4196− };
4197−
4198− let _ = app.update(Event::LoadedSettings(Ok(None)), &mut m);
4199− let _ = app.update(Event::LoadedProfile(Ok(None)), &mut m);
4200− let _ = app.update(Event::LoadedContacts(Ok(None)), &mut m);
4201− let _ = app.update(Event::LoadedCursor(Ok(None)), &mut m);
4202− // The outbox is the one that could not be read — a background wake with the
4203− // device locked. The model now believes it is empty; the disk says otherwise.
4204− let mut cmd = app.update(
4205− Event::LoadedOutbox(Err(crux_kv::error::KeyValueError::Io {
4206− message: "device is locked".into(),
4207− })),
4208− &mut m,
4209− );
4210−
4211− assert!(m.kv_load_failed, "the model knows it has a hole in it");
4212− assert!(m.error.is_some(), "and says so");
4213− assert_eq!(
4214− connects(&mut cmd),
4215− 0,
4216− "the last load does not connect: a poll would ack mail we cannot store"
4217− );
4218−
4219− // Every write path is closed, whatever tries to open it.
4220− let mut cmd = app.update(
4221− Event::AddContact {
4222− input: peer_hex(9),
4223− nickname: "n".into(),
4224− },
4225− &mut m,
4226− );
4227− assert!(
4228− kv_writes(&mut cmd).is_empty(),
4229− "contacts are not written over an unread model"
4230− );
4231−
4232− // And the regression itself. `ingest_poll` persists the outbox on every page,
4233− // so one idle poll used to be enough to write `[]` over the queue on disk.
4234− m.token = Some("t".into());
4235− let resp = crux_http::testing::ResponseBuilder::ok()
4236− .body(br#"{"events":[],"cursor":42}"#.to_vec())
4237− .build();
4238− let mut cmd = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4239− assert!(
4240− kv_writes(&mut cmd).is_empty(),
4241− "an empty poll writes nothing — not the outbox, not the cursor"
4242− );
4243−
4244− // The recovery is a relaunch, which re-runs the loads and clears the latch.
4245− let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
4246− let _ = app.update(Event::IdentityLoaded(sk), &mut m);
4247− assert!(
4248− !m.kv_load_failed,
4249− "a fresh round of loads gets to answer for itself"
4250− );
4251− }
4252−
4253− /// `Authenticate` is reachable from startup, a 401 on poll, a 401 on send and
4254− /// a reconnect timer. Without a guard a network blip fires all of them, and
4255− /// every token that comes back starts its own poll loop.
4256− #[test]
4257− fn authentication_does_not_stack() {
4258− let app = Skrepka;
4259− let mut m = with_identity();
4260−
4261− let _ = app.update(Event::Authenticate, &mut m);
4262− assert!(m.authenticating, "the first one goes out");
4263− assert_eq!(m.conn, ConnStatus::Connecting);
4264−
4265− // A second Authenticate while the first is in flight is a no-op.
4266− m.conn = ConnStatus::Offline;
4267− let _ = app.update(Event::Authenticate, &mut m);
4268− assert_eq!(m.conn, ConnStatus::Offline, "no second challenge was issued");
4269−
4270− // Any terminal outcome releases the guard.
4271− let _ = app.update(
4272− Event::ChallengeResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4273− &mut m,
4274− );
4275− assert!(!m.authenticating);
4276−
4277− let _ = app.update(Event::Authenticate, &mut m);
4278− assert!(m.authenticating);
4279− let _ = app.update(
4280− Event::VerifyResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4281− &mut m,
4282− );
4283− assert!(!m.authenticating);
4284− }
4285−
4286− /// The shell fires `Connect` on *every* return to the foreground
4287− /// (`SkrepkaApp.swift`), so a challenge issued before the app was backgrounded
4288− /// is routinely still in flight when the next attempt starts. It must not come
4289− /// back and install a token, or clobber `conn`, on top of the attempt that
4290− /// replaced it.
4291− #[test]
4292− fn an_auth_round_trip_superseded_by_connect_cannot_land() {
4293− let app = Skrepka;
4294− let mut m = with_identity();
4295−
4296− let _ = app.update(Event::Authenticate, &mut m);
4297− let superseded = m.auth_gen;
4298− assert!(m.authenticating);
4299−
4300− // Foregrounding restarts the flow.
4301− dispatch(&app, Event::Connect, &mut m);
4302− let live = m.auth_gen;
4303− assert_ne!(live, superseded, "the in-flight attempt was retired");
4304− assert!(m.authenticating, "and a fresh one went out");
4305−
4306− // The old challenge finally answers. It is not ours any more.
4307− let body = serde_json::to_vec(&serde_json::json!({"challenge": "deadbeef"})).unwrap();
4308− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4309− let _ = app.update(Event::ChallengeResult(superseded, Ok(resp)), &mut m);
4310− assert!(
4311− m.authenticating,
4312− "the live attempt still holds the guard — a stale leg must not release it"
4313− );
4314−
4315− // Nor may a stale *verify* install a token over the live attempt.
4316− let body = serde_json::to_vec(&serde_json::json!({"token": "stale"})).unwrap();
4317− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4318− let _ = app.update(Event::VerifyResult(superseded, Ok(resp)), &mut m);
4319− assert!(m.token.is_none(), "no token from a superseded attempt");
4320− assert_ne!(m.conn, ConnStatus::Online);
4321−
4322− // The live attempt still completes normally.
4323− let body = serde_json::to_vec(&serde_json::json!({"token": "real"})).unwrap();
4324− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4325− let _ = app.update(Event::VerifyResult(live, Ok(resp)), &mut m);
4326− assert_eq!(m.token.as_deref(), Some("real"));
4327− assert_eq!(m.conn, ConnStatus::Online);
4328− }
4329−
4330− /// Blocking is meant to cut a peer off in *both* directions.
4331− #[test]
4332− fn a_blocked_peer_cannot_update_their_profile_or_ack_our_messages() {
4333− let app = Skrepka;
4334− let mut m = with_identity();
4335− let peer = peer_hex(9);
4336− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4337− contact.blocked = true;
4338− contact.display_name = "Bob".into();
4339− m.contacts.insert(peer.clone(), contact);
4340− m.messages.insert(
4341− peer.clone(),
4342− vec![StoredMessage {
4343− id: "mine".into(),
4344− body: "hi".into(),
4345− ts: 1,
4346− outgoing: true,
4347− delivered: false,
4348− }],
4349− );
4350−
4351− let profile = Payload::Profile {
4352− display_name: Some("Mallory".into()),
4353− bio: Some("spam".into()),
4354− photo: Some("aGk=".into()),
4355− };
4356− let page = page_from(9, &m, &profile, now_ms());
4357− let _ = app.ingest_poll(&mut m, page);
4358− assert_eq!(
4359− m.contacts[&peer].display_name, "Bob",
4360− "a blocked peer cannot rewrite their entry in our contact list"
4361− );
4362−
4363− let ack = Payload::DeliveryAck {
4364− ack_ids: vec!["mine".into()],
4365− };
4366− let page = page_from(9, &m, &ack, now_ms());
4367− let _ = app.ingest_poll(&mut m, page);
4368− assert!(
4369− !m.messages[&peer][0].delivered,
4370− "nor tell us they read what we sent before the block"
4371− );
4372− }
4373−
4374− /// Blocking someone and then updating our profile must not hand them our new
4375− /// display name, avatar, and a liveness signal.
4376− #[test]
4377− fn save_profile_does_not_broadcast_to_blocked_contacts() {
4378− let app = Skrepka;
4379− let mut m = with_identity();
4380− let friend = peer_hex(9);
4381− let enemy = peer_hex(11);
4382− m.contacts
4383− .insert(friend.clone(), Contact::new(friend.clone(), "Bob".into(), 0));
4384− let mut blocked = Contact::new(enemy.clone(), "Mallory".into(), 0);
4385− blocked.blocked = true;
4386− m.contacts.insert(enemy.clone(), blocked);
4387−
4388− let _ = app.update(
4389− Event::SaveProfile {
4390− display_name: "Alice".into(),
4391− bio: String::new(),
4392− photo: None,
4393− },
4394− &mut m,
4395− );
4396−
4397− let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
4398− assert_eq!(recipients, vec![friend.as_str()]);
4399− }
4400−
4401− /// Any stranger can create a contact just by writing to us, so the list needs
4402− /// a ceiling — and past it, the message goes too: a stored conversation with
4403− /// no contact entry is one nothing can open and nothing ever trims.
4404− #[test]
4405− fn contacts_from_strangers_are_capped() {
4406− let app = Skrepka;
4407− let mut m = with_identity();
4408− for i in 0..MAX_CONTACTS {
4409− let k = format!("{i:064x}");
4410− m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
4411− }
4412− assert_eq!(m.contacts.len(), MAX_CONTACTS);
4413−
4414− let peer = peer_hex(9);
4415− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4416− let _ = app.ingest_poll(&mut m, page);
4417−
4418− assert_eq!(m.contacts.len(), MAX_CONTACTS, "the cap holds");
4419− assert!(!m.contacts.contains_key(&peer));
4420− assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4421− assert!(m.outbox.is_empty(), "and nothing is acked back");
4422− }
4423−
4424− /// A bare profile is an unsolicited push with no message attached. Honouring
4425− /// it would let anyone holding our pubkey install an entry — with an avatar —
4426− /// in our contact list without ever saying a word.
4427− #[test]
4428− fn a_profile_from_a_stranger_does_not_create_a_contact() {
4429− let app = Skrepka;
4430− let mut m = with_identity();
4431− let peer = peer_hex(9);
4432−
4433− let profile = Payload::Profile {
4434− display_name: Some("Mallory".into()),
4435− bio: Some(String::new()),
4436− photo: Some("aGk=".into()),
4437− };
4438− let page = page_from(9, &m, &profile, now_ms());
4439− let _ = app.ingest_poll(&mut m, page);
4440− assert!(m.contacts.is_empty(), "no contact from a bare profile");
4441−
4442− // But a profile from someone we *do* know still applies.
4443− m.contacts
4444− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4445− let page = page_from(9, &m, &profile, now_ms());
4446− let _ = app.ingest_poll(&mut m, page);
4447− assert_eq!(m.contacts[&peer].display_name, "Mallory");
4448− }
4449−
4450− /// A relay cannot forge mail from us — the sender identity is signed inside the
4451− /// AEAD — but it can *echo back* a blob we sent through it, and one addressed to
4452− /// ourselves decrypts perfectly well. Nothing downstream would notice: it would
4453− /// file our own words as incoming, put our own key in our contact list (which
4454− /// `AddContact` explicitly refuses), and queue a delivery ack addressed to us —
4455− /// which flushes, comes back, and is acked again.
4456− #[test]
4457− fn a_message_from_ourselves_is_dropped() {
4458− let app = Skrepka;
4459− let mut m = with_identity();
4460− let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4461−
4462− let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
4463− let blob = crate::crypto::encrypt(
4464− &mut rand_core::OsRng,
4465− &me,
4466− &me.public_key(),
4467− json.as_bytes(),
4468− )
4469− .unwrap();
4470− let page = PollResp {
4471− events: vec![PollEvent {
4472− encrypted_blob: hex::encode(blob),
4473− }],
4474− cursor: 1,
4475− };
4476−
4477− let _ = app.ingest_poll(&mut m, page);
4478−
4479− assert!(
4480− !m.contacts.contains_key(&m.my_pubkey),
4481− "we are not our own contact"
4482− );
4483− assert!(m.messages.is_empty(), "and our own words are not incoming mail");
4484− assert!(m.outbox.is_empty(), "and we do not ack ourselves");
4485− }
4486−
4487− /// `polling` gates the whole long-poll loop and is cleared only by a
4488− /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
4489− /// silent forever with no visible error.
4490− #[test]
4491− fn the_watchdog_unwedges_a_poll_that_never_resolved() {
4492− let app = Skrepka;
4493− let mut m = with_identity();
4494− m.token = Some("t".into());
4495−
4496− let _ = app.update(Event::Poll, &mut m);
4497− assert!(m.polling);
4498− let wedged = m.poll_gen;
4499−
4500− // The watchdog armed for *this* poll releases the guard and restarts the
4501− // loop — which issues a fresh poll under a new generation.
4502− dispatch(&app, Event::PollWatchdog(wedged), &mut m);
4503− assert!(m.polling, "the loop restarted");
4504− assert_ne!(m.poll_gen, wedged, "under a new generation");
4505− }
4506−
4507− /// A watchdog is armed per poll and nothing cancels the earlier ones, so a
4508− /// timer for a poll that has already completed keeps firing. It must not
4509− /// touch the healthy poll that replaced it.
4510− #[test]
4511− fn a_stale_watchdog_leaves_the_current_poll_alone() {
4512− let app = Skrepka;
4513− let mut m = with_identity();
4514− m.token = Some("t".into());
4515−
4516− let _ = app.update(Event::Poll, &mut m);
4517− let first = m.poll_gen;
4518− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4519− let _ = app.update(Event::PollResult(first, Ok(resp)), &mut m);
4520− let _ = app.update(Event::Poll, &mut m);
4521− let current = m.poll_gen;
4522− assert!(m.polling);
4523−
4524− let _ = app.update(Event::PollWatchdog(first), &mut m);
4525− assert!(m.polling, "the live poll is untouched");
4526− assert_eq!(m.poll_gen, current, "and not superseded");
4527− }
4528−
4529− /// The regression the generation exists for.
4530− ///
4531− /// An HTTP effect cannot be cancelled, only abandoned — and it stays pending
4532− /// across an iOS suspension while the wall clock runs on, so on resume the
4533− /// watchdog gives up on a poll the shell is still about to resolve. If that
4534− /// late `PollResult` were honoured it would clear `polling` — a guard that by
4535− /// then belongs to the *replacement* poll — and chain a re-poll of its own.
4536− /// Two loops, each re-polling itself forever, doubling on every recurrence.
4537− #[test]
4538− fn a_poll_abandoned_by_the_watchdog_cannot_fork_the_loop_when_it_lands() {
4539− let app = Skrepka;
4540− let mut m = with_identity();
4541− m.token = Some("t".into());
4542−
4543− let _ = app.update(Event::Poll, &mut m);
4544− let abandoned = m.poll_gen;
4545−
4546− // The watchdog gives up on it; the loop restarts under a new generation.
4547− dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4548− let live = m.poll_gen;
4549− assert!(m.polling && live != abandoned);
4550−
4551− // Now the abandoned request finally resolves. It must be ignored outright.
4552− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4553− let mut cmd = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4554−
4555− assert!(m.polling, "the live poll still holds the guard");
4556− assert_eq!(m.poll_gen, live);
4557− assert_eq!(
4558− cmd.events().count(),
4559− 0,
4560− "and the corpse chains nothing — no second loop"
4561− );
4562− }
4563−
4564− /// Dropping an abandoned page loses nothing: its cursor was never written, so
4565− /// the relay still holds every message in it and re-delivers them.
4566− #[test]
4567− fn an_abandoned_poll_page_is_not_ingested() {
4568− let app = Skrepka;
4569− let mut m = with_identity();
4570− m.token = Some("t".into());
4571− let peer = peer_hex(9);
4572−
4573− let _ = app.update(Event::Poll, &mut m);
4574− let abandoned = m.poll_gen;
4575− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4576− let body = serde_json::to_vec(&serde_json::json!({
4577− "events": [{"encryptedBlob": page.events[0].encrypted_blob}],
4578− "cursor": 12345,
4579− }))
4580− .unwrap();
4581−
4582− dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4583− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4584− let _ = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4585−
4586− assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4587− assert_eq!(m.cursor, 0, "and the stale cursor is not adopted");
4588− }
4589−
4590− /// `view()` runs per keystroke (`ComposeChanged` renders), so cloning every
4591− /// contact's base64 avatar each time meant megabytes of allocation to type one
4592− /// message. Only the chat page has a composer, and while it is up the only
4593− /// avatar on screen is the peer we are talking to — so that is the only page
4594− /// that withholds anything.
4595− ///
4596− /// The conversations list draws an avatar per row (`ConversationsView.row`).
4597− /// Blanking photos there buys nothing — no keystroke renders that page — and
4598− /// costs every contact their picture.
4599− #[test]
4600− fn photos_are_withheld_on_the_chat_page_and_nowhere_else() {
4601− let app = Skrepka;
4602− let mut m = with_identity();
4603− let a = peer_hex(9);
4604− let b = peer_hex(11);
4605− for peer in [&a, &b] {
4606− let mut c = Contact::new(peer.clone(), String::new(), 0);
4607− c.photo = Some("aGVsbG8=".into());
4608− m.contacts.insert(peer.clone(), c);
4609− }
4610− let photo_of = |vm: &ViewModel, k: &str| {
4611− vm.contacts
4612− .iter()
4613− .find(|c| c.pubkey == k)
4614− .map(|c| c.photo.clone())
4615− .unwrap()
4616− };
4617−
4618− // Conversations list: every row keeps its avatar.
4619− let vm = app.view(&m);
4620− assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4621− assert_eq!(photo_of(&vm, &b), "aGVsbG8=");
4622−
4623− // Open a chat: exactly that peer's photo, and no one else's — this is the
4624− // page that re-renders on every keystroke.
4625− m.page = Page::Chat;
4626− m.active_peer = Some(a.clone());
4627− let vm = app.view(&m);
4628− assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4629− assert!(photo_of(&vm, &b).is_empty());
4630− }
4631−
4632− /// `parse_payload` drops a `delivery.ack` with more than `MAX_ACK_IDS` ids, and
4633− /// a peer running this code applies that cap to us. One ack payload per sender
4634− /// therefore stops working the moment a peer has more than the cap waiting for
4635− /// us — a long offline stretch — and those messages stay un-delivered on their
4636− /// side forever, because a page is only ever acked once.
4637− #[test]
4638− fn acks_are_batched_so_the_peer_can_actually_parse_them() {
4639− let app = Skrepka;
4640− let mut m = with_identity();
4641− let peer = peer_hex(9);
4642− m.contacts
4643− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4644−
4645− // One page holding more messages from one sender than fits in a single ack.
4646− let count = protocol::MAX_ACK_IDS + 5;
4647− let sender = Identity::from_seed(&[9u8; 32]).unwrap();
4648− let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4649− let events = (0..count)
4650− .map(|i| {
4651− let json = protocol::serialize_payload(&text(&format!("m{i}"), "hi"), now_ms());
4652− let blob = crate::crypto::encrypt(
4653− &mut rand_core::OsRng,
4654− &sender,
4655− &me.public_key(),
4656− json.as_bytes(),
4657− )
4658− .unwrap();
4659− PollEvent {
4660− encrypted_blob: hex::encode(blob),
4661− }
4662− })
4663− .collect();
4664− let _ = app.ingest_poll(&mut m, PollResp { events, cursor: 1 });
4665−
4666− // Local history is capped as always — but every message we *received* is
4667− // acked, whether or not we chose to keep it.
4668− assert_eq!(m.messages[&peer].len(), MAX_MESSAGES_PER_PEER);
4669− assert_eq!(m.outbox.len(), 2, "the acks are split into parseable batches");
4670−
4671− // And every batch really does survive the recipient's parser.
4672− let mut acked = 0;
4673− for item in &m.outbox {
4674− assert_eq!(item.recipient, peer);
4675− let parsed = protocol::parse_payload(item.envelope_json.as_bytes())
4676− .expect("a peer must be able to parse the ack we send it");
4677− match parsed.payload {
4678− Payload::DeliveryAck { ack_ids } => {
4679− assert!(ack_ids.len() <= protocol::MAX_ACK_IDS);
4680− acked += ack_ids.len();
4681− }
4682− _ => panic!("expected a delivery.ack"),
4683− }
4684− }
4685− assert_eq!(acked, count, "and between them they ack the whole page");
4686− }
4687−
4688− #[test]
4689− fn view_exposes_identity_and_ob() {
4690− let app = Skrepka;
4691− let m = with_identity();
4692− let vm = app.view(&m);
4693− assert!(vm.has_identity);
4694− assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
4695− assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
4696− }
4697−
4698− /// A replayed delivery.ack with an older `ts` must be dropped, and
4699− /// `last_ack_ts` must not regress — otherwise a relay re-delivering an old
4700− /// page could flip messages back to "not delivered" or keep a stale ack live.
4701− #[test]
4702− fn a_replayed_delivery_ack_is_ignored() {
4703− let app = Skrepka;
4704− let mut m = with_identity();
4705− let peer = peer_hex(9);
4706− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4707− contact.last_ack_ts = 100;
4708− m.contacts.insert(peer.clone(), contact);
4709− m.messages.insert(
4710− peer.clone(),
4711− vec![StoredMessage {
4712− id: "m1".into(),
4713− body: "hi".into(),
4714− ts: 50,
4715− outgoing: true,
4716− delivered: false,
4717− }],
4718− );
4719−
4720− // An ack with ts = 50 (< 100) is stale → ignored.
4721− let ack = Payload::DeliveryAck {
4722− ack_ids: vec!["m1".into()],
4723− };
4724− let page = page_from(9, &m, &ack, 50);
4725− let _ = app.ingest_poll(&mut m, page);
4726− assert!(
4727− !m.messages[&peer][0].delivered,
4728− "a stale ack must not mark the message delivered"
4729− );
4730− assert_eq!(
4731− m.contacts[&peer].last_ack_ts, 100,
4732− "last_ack_ts must not regress"
4733− );
4734−
4735− // An ack with ts = 200 (> 100) passes → message is marked delivered.
4736− let ack = Payload::DeliveryAck {
4737− ack_ids: vec!["m1".into()],
4738− };
4739− let page = page_from(9, &m, &ack, 200);
4740− let _ = app.ingest_poll(&mut m, page);
4741− assert!(
4742− m.messages[&peer][0].delivered,
4743− "a fresh ack must mark the message delivered"
4744− );
4745− assert_eq!(
4746− m.contacts[&peer].last_ack_ts, 200,
4747− "last_ack_ts advances to the fresh ack's ts"
4748− );
4749− }
4750−
4751− // -----------------------------------------------------------------------
4752− // cursor restoration on budget exhaustion
4753− // -----------------------------------------------------------------------
4754−
4755− /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4756− /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4757− /// must be restored to its pre-page value. Otherwise the next poll acks the
4758− /// whole page to the relay, which deletes every event we skipped: permanent
4759− /// message loss.
4760− #[test]
4761− fn a_budget_exhausted_mid_page_restores_the_cursor() {
4762− let app = Skrepka;
4763− let mut m = with_identity();
4764− let peer = peer_hex(9);
4765− m.contacts
4766− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4767− m.cursor = 100;
4768−
4769− // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4770− // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4771− // per-blob size gate; together they exceed the 64 MiB page budget.
4772− // The first passes the budget check (21 < 64) and fails decrypt (it's
4773− // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4774− // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4775− let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4776− let page = PollResp {
4777− events: (0..4)
4778− .map(|_| PollEvent {
4779− encrypted_blob: big_hex.clone(),
4780− })
4781− .collect(),
4782− cursor: 200,
4783− };
4784− let _ = app.ingest_poll(&mut m, page);
4785−
4786− assert_eq!(
4787− m.cursor, 100,
4788− "the cursor must be restored — the page was not fully processed"
4789− );
4790− assert!(
4791− m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4792− "the user is told"
4793− );
4794− assert!(
4795− m.messages.get(&peer).is_none_or(Vec::is_empty),
4796− "nothing was stored from the oversized page"
4797− );
4798− }
4799−
4800− // -----------------------------------------------------------------------
4801− // send_text block check
4802− // -----------------------------------------------------------------------
4803−
4804− // -----------------------------------------------------------------------
4805− // send_text body length check
4806− // -----------------------------------------------------------------------
4807−
4808− /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4809− /// enqueue a message to a blocked contact — otherwise the block only stops
4810− /// their incoming mail while we keep sending them ours.
4811− #[test]
4812− fn send_text_to_a_blocked_peer_is_refused() {
4813− let app = Skrepka;
4814− let mut m = with_identity();
4815− let peer = peer_hex(9);
4816− let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4817− contact.blocked = true;
4818− m.contacts.insert(peer.clone(), contact);
4819− m.active_peer = Some(peer.clone());
4820− m.compose = "hello?".into();
4821−
4822− let _ = app.update(Event::SendText, &mut m);
4823−
4824− assert!(
4825− m.outbox.is_empty(),
4826− "no message is queued for a blocked peer"
4827− );
4828− assert!(
4829− m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4830− "the user is told why"
4831− );
4832− // The compose buffer is not cleared — the user might want to unblock
4833− // and resend, and losing their draft would be surprising.
4834− assert_eq!(m.compose, "hello?");
4835− }
4836−
4837− /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4838− /// relay, but the recipient's `parse_payload` silently drops oversized text.
4839− /// The sender would see it stuck as "sent" forever with no error.
4840− #[test]
4841− fn send_text_refuses_an_oversized_body() {
4842− let app = Skrepka;
4843− let mut m = with_identity();
4844− let peer = peer_hex(9);
4845− m.active_peer = Some(peer.clone());
4846− m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4847−
4848− let _ = app.update(Event::SendText, &mut m);
4849−
4850− assert!(m.outbox.is_empty(), "nothing is queued");
4851− assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4852− assert!(!m.compose.is_empty(), "compose is preserved");
4853− assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4854− }
4855−
4856− /// A stale error from a previous failed send must be cleared on the next
4857− /// attempt, so the error display does not persist after the user fixes the
4858− /// issue and sends again successfully.
4859− #[test]
4860− fn send_text_clears_a_stale_error_on_success() {
4861− let app = Skrepka;
4862− let mut m = with_identity();
4863− let peer = peer_hex(9);
4864− m.active_peer = Some(peer.clone());
4865− // Simulate a prior failure that left an error behind.
4866− m.error = Some("message too long".into());
4867−
4868− m.compose = "hello".into();
4869− let _ = app.update(Event::SendText, &mut m);
4870−
4871− assert!(m.error.is_none(), "a successful send clears the stale error");
4872− assert!(!m.outbox.is_empty(), "the message was queued");
4873− }
4874−
4875− // -----------------------------------------------------------------------
4876− // SaveProfile field length checks
4877− // -----------------------------------------------------------------------
4878−
4879− #[test]
4880− fn save_profile_rejects_an_oversized_display_name() {
4881− let app = Skrepka;
4882− let mut m = with_identity();
4883−
4884− let _ = app.update(Event::SaveProfile {
4885− display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4886− bio: String::new(),
4887− photo: None,
4888− }, &mut m);
4889−
4890− assert!(m.outbox.is_empty(), "no broadcast queued");
4891− assert!(m.profile.display_name.is_empty(), "local profile not updated");
4892− assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4893− }
4894−
4895− #[test]
4896− fn save_profile_rejects_an_oversized_bio() {
4897− let app = Skrepka;
4898− let mut m = with_identity();
4899−
4900− let _ = app.update(Event::SaveProfile {
4901− display_name: "Alice".into(),
4902− bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4903− photo: None,
4904− }, &mut m);
4905−
4906− assert!(m.outbox.is_empty(), "no broadcast queued");
4907− assert!(m.profile.bio.is_empty(), "local profile not updated");
4908− assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4909− }
4910−
4911− #[test]
4912− fn save_profile_rejects_an_oversized_photo() {
4913− let app = Skrepka;
4914− let mut m = with_identity();
4915−
4916− let _ = app.update(Event::SaveProfile {
4917− display_name: "Alice".into(),
4918− bio: String::new(),
4919− photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4920− }, &mut m);
4921−
4922− assert!(m.outbox.is_empty(), "no broadcast queued");
4923− assert!(m.profile.photo.is_none(), "local profile not updated");
4924− assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4925− }
4926−
4927− // -----------------------------------------------------------------------
4928− // AddContact nickname length check
4929− // -----------------------------------------------------------------------
4930−
4931− #[test]
4932− fn add_contact_rejects_an_oversized_nickname() {
4933− let app = Skrepka;
4934− let mut m = with_identity();
4935− let peer = peer_hex(9);
4936−
4937− let _ = app.update(Event::AddContact {
4938− input: peer.clone(),
4939− nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4940− }, &mut m);
4941−
4942− assert!(!m.contacts.contains_key(&peer), "no contact created");
4943− assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4944− }
4945−
4946− // -----------------------------------------------------------------------
4947− // FlushWatchdog unsticks a wedged send loop
4948− // -----------------------------------------------------------------------
4949−
4950− /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4951− /// effect, the outbox is permanently stuck. The watchdog releases the guard
4952− /// so the flush retries.
4953− #[test]
4954− fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4955− let app = Skrepka;
4956− let mut m = with_identity();
4957− m.token = Some("t".into());
4958− let peer = peer_hex(9);
4959− m.outbox.push_back(queued(&peer, "stuck"));
4960−
4961− let _ = app.update(Event::StartFlush, &mut m);
4962− assert!(m.flushing, "the send is in flight");
4963− assert!(m.in_flight.is_some());
4964−
4965− // The send never comes back. The watchdog fires, clears the stuck guard,
4966− // and retries the flush — the message goes out again.
4967− let mut cmd = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4968−
4969− assert!(m.flushing, "a fresh send is in flight (the retry)");
4970− assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4971− let batches = sent_batches(&mut cmd);
4972− assert_eq!(batches.len(), 1, "the message is re-sent");
4973− }
4974−
4975− /// A watchdog that fires after the send already completed is a no-op.
4976− #[test]
4977− fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4978− let app = Skrepka;
4979− let mut m = with_identity();
4980− m.token = Some("t".into());
4981− m.outbox.push_back(queued(&peer_hex(9), "hi"));
4982−
4983− let _ = app.update(Event::StartFlush, &mut m);
4984− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4985− assert!(!m.flushing);
4986− assert!(m.outbox.is_empty());
4987−
4988− // The watchdog for the completed send fires late.
4989− let _ = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4990− assert!(!m.flushing, "a completed send is not re-armed");
4991− assert!(m.outbox.is_empty());
4992− }
4993−
4994− /// A late `SendResult` from a superseded send is dropped by the generation
4995− /// check. The watchdog abandoned the first send and retried under a new
4996− /// generation; the late result's generation no longer matches, so it cannot
4997− /// pop the retry's items or clear its guard. The retry's own `SendResult`
4998− /// is what eventually pops them.
4999− #[test]
5000− fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
5001− let app = Skrepka;
5002− let mut m = with_identity();
5003− m.token = Some("t".into());
5004− let peer = peer_hex(9);
5005− m.outbox.push_back(queued(&peer, "stuck"));
5006−
5007− let _ = app.update(Event::StartFlush, &mut m);
5008− let first_gen = m.flush_gen;
5009− // The watchdog fires and retries the flush under a new generation.
5010− let _ = app.update(Event::FlushWatchdog(first_gen), &mut m);
5011− assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
5012− assert_ne!(m.flush_gen, first_gen, "the generation was bumped");
5013− assert!(m.flushing, "the retry is in flight");
5014−
5015− // The original send's result arrives late. Its generation no longer
5016− // matches, so it is dropped — it cannot pop the retry's items or
5017− // clear the retry's guard.
5018− let _ = app.update(Event::SendResult(first_gen, Ok(ok_response(200))), &mut m);
5019− assert!(m.flushing, "the retry's guard is untouched");
5020− assert_eq!(m.outbox.len(), 1, "the late result popped nothing");
5021−
5022− // The retry's own result arrives and pops the items.
5023− let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
5024− assert!(m.outbox.is_empty(), "the retry's result pops the items");
5025− assert!(!m.flushing, "and the guard is released");
5026− }
5027−}