@@ -1,5027 +0,0 @@
1−//! Crux App — the MVU state machine for the skrepka client.
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.
8−use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
10−use std::time::{Duration, SystemTime, UNIX_EPOCH};
12−use base64::prelude::*;
17− render::{render, RenderOperation},
20−use crux_http::protocol::HttpRequest;
21−use crux_kv::KeyValueOperation;
22−use crux_time::TimeRequest;
24−use serde::{Deserialize, Serialize};
26−use zeroize::Zeroizing;
28−use crate::crypto::{Identity, MAX_BLOB_LEN};
30− hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
31− Settings, StoredMessage, ViewModel, DEFAULT_SERVER_URL, MAX_MESSAGES_PER_PEER,
34−use crate::protocol::{self, Envelope, Payload};
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>;
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;
47−/// Cap on stored contacts.
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;
56−/// Cap on the total blob bytes we will decrypt out of a single poll page.
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;
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;
73−/// Upper bound on a poll response body we will buffer and parse, in bytes.
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;
82−/// How long a poll may stay in flight before we assume the shell lost it.
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.
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;
97−/// How long a send may stay in flight before we assume the shell lost it.
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.
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;
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;
117−/// Messages per `/messages` request.
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;
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.
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;
135−/// Ceiling on a `Retry-After` we will honour from a relay.
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
142−const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
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}")
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;
162−// ---------------------------------------------------------------------------
164−// ---------------------------------------------------------------------------
166−#[effect(facet_typegen)]
169− Render(RenderOperation),
171− KeyValue(KeyValueOperation),
175−// ---------------------------------------------------------------------------
177−// ---------------------------------------------------------------------------
179−#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
182− // ---- lifecycle / persistence (from the shell) ----
183− /// 64-byte Ed25519 secret key loaded (or freshly generated) by the shell.
184− IdentityLoaded(Vec<u8>),
186− // ---- navigation (from the shell) ----
194− // ---- user actions (from the shell) ----
195− ComposeChanged(String),
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 },
204− display_name: String,
206− photo: Option<String>,
208− SetServerUrl(String),
211− // ---- internal: kv load results ----
214− LoadedSettings(#[facet(opaque)] KvData),
217− LoadedProfile(#[facet(opaque)] KvData),
220− LoadedContacts(#[facet(opaque)] KvData),
223− LoadedCursor(#[facet(opaque)] KvData),
226− LoadedOutbox(#[facet(opaque)] KvData),
229− LoadedSeenIds(#[facet(opaque)] KvData),
232− LoadedMessages(String, #[facet(opaque)] KvData),
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.
240− SavedCursor(#[facet(opaque)] KvData),
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.
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`.
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.
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
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.
278− ChallengeResult(u64, #[facet(opaque)] HttpResult),
281− VerifyResult(u64, #[facet(opaque)] HttpResult),
284− PollResult(u64, #[facet(opaque)] HttpResult),
287− SendResult(u64, #[facet(opaque)] HttpResult),
290−// ---------------------------------------------------------------------------
292−// ---------------------------------------------------------------------------
294−#[derive(Clone, Copy, PartialEq, Eq, Debug)]
295−pub enum ConnStatus {
301−#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
303− /// The shell renders a spinner until `IdentityLoaded` arrives, so there is
304− /// no distinct pre-identity page — conversations is the landing page.
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;
317−/// The batch a live `/messages` request is carrying: the first `count` items of
318−/// the outbox, all bound for `recipient`.
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`).
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>>>,
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>,
349− token: Option<String>,
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.
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).
361− /// Which poll the in-flight `PollResult` is allowed to belong to.
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.
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.
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
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.
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.
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.
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
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.
419− /// A startup kv *read* failed — as opposed to finding the key absent.
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.
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.
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,
445− active_peer: Option<String>,
447− error: Option<String>,
450−impl Default for Model {
451− fn default() -> Self {
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(),
463− conn: ConnStatus::Offline,
468− authenticating: false,
473− send_batch_budget: None,
475− kv_load_failed: false,
476− flush_paused_until: 0,
477− page: Page::default(),
479− compose: String::new(),
486− fn identity(&self) -> Option<Identity> {
489− .and_then(|sk| Identity::from_secret_bytes(sk).ok())
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 {
498− self.loads_pending -= 1;
499− self.loads_pending == 0
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);
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);
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);
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);
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);
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);
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.
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;
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)
568−// ---------------------------------------------------------------------------
570−// ---------------------------------------------------------------------------
572−/// Wall-clock milliseconds.
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
585− .duration_since(UNIX_EPOCH)
586− .map(|d| d.as_millis() as i64)
590−/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
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 {
599− .and_then(|u| u.host_str().map(str::to_string))
600− .map(|h| h.trim_end_matches('.').to_lowercase())
601− .unwrap_or_default()
604−/// Validate and canonicalize a user-entered relay URL.
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.
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.
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") {
622− if url.host_str().is_none_or(str::is_empty) {
625− if url.query().is_some() || url.fragment().is_some() {
628− Some(url.as_str().trim_end_matches('/').to_string())
631−fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
632− serde_json::to_vec(value).unwrap_or_default()
635−// ---------------------------------------------------------------------------
636−// Wire request/response shapes
637−// ---------------------------------------------------------------------------
640−struct ChallengeReq<'a> {
643−#[derive(Deserialize)]
644−struct ChallengeResp {
648−struct VerifyReq<'a> {
652− #[serde(rename = "revokeOthers")]
653− revoke_others: bool,
655−#[derive(Deserialize)]
663−#[derive(Deserialize, Default)]
666− events: Vec<PollEvent>,
670−#[derive(Deserialize)]
672− #[serde(rename = "encryptedBlob")]
673− encrypted_blob: String,
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.
683− ids: HashSet<String>,
687− messages: Vec<Envelope>,
690−// ---------------------------------------------------------------------------
692−// ---------------------------------------------------------------------------
697−impl App for Skrepka {
700− type ViewModel = ViewModel;
701− type Effect = Effect;
703− #[allow(clippy::too_many_lines)]
704− fn update(&self, event: Event, model: &mut Model) -> Command<Effect, 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());
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;
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),
733− Event::LoadedSettings(res) => {
734− match parse_kv::<Settings>(res) {
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());
747− model.kv_load_failed = true;
748− model.error = Some("could not read settings from storage".into());
751− self.startup_done(model).and(render())
753− Event::LoadedProfile(res) => {
754− match parse_kv::<OwnProfile>(res) {
755− Ok(Some(p)) => model.profile = p,
758− model.kv_load_failed = true;
759− model.error = Some("could not read profile from storage".into());
762− self.startup_done(model).and(render())
764− Event::LoadedContacts(res) => {
765− let mut cmds = vec![self.startup_done(model)];
766− match parse_kv::<Vec<Contact>>(res) {
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))
778− model.kv_load_failed = true;
779− model.error = Some("could not read contacts from storage".into());
782− Command::all(cmds).and(render())
784− Event::LoadedCursor(res) => {
785− match parse_kv::<i64>(res) {
786− Ok(Some(c)) => model.cursor = c,
789− model.kv_load_failed = true;
790− model.error = Some("could not read cursor from storage".into());
793− self.startup_done(model).and(render())
795− Event::LoadedOutbox(res) => {
796− match parse_kv::<Vec<OutboxItem>>(res) {
797− Ok(Some(list)) => model.outbox = list.into(),
800− model.kv_load_failed = true;
801− model.error = Some("could not read outbox from storage".into());
804− self.startup_done(model).and(render())
806− Event::LoadedSeenIds(res) => {
807− match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808− Ok(Some(map)) => model.seen_ids = map,
811− // Non-fatal: dedup falls back to the conversation-derived
812− // set, which is weaker but functional.
813− model.seen_ids = BTreeMap::new();
816− self.startup_done(model).and(render())
818− Event::LoadedMessages(peer, res) => {
819− match parse_kv::<Vec<StoredMessage>>(res) {
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()));
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) {
836− let existing_ids: HashSet<&String> =
837− existing.iter().map(|m| &m.id).collect();
838− let new_msgs: Vec<StoredMessage> = list
840− .filter(|m| !existing_ids.contains(&m.id))
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);
848− model.messages.insert(peer, list);
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());
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}"));
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);
886− Command::event(Event::Poll)
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;
899− Event::ShowSettings => {
900− model.page = Page::Settings;
904− Event::ShowAddContact => {
905− model.page = Page::AddContact;
909− Event::ShowEditProfile => {
910− model.page = Page::EditProfile;
914− Event::OpenChat(peer) => {
915− model.active_peer = Some(peer);
916− model.page = Page::Chat;
921− model.page = Page::Conversations;
922− model.active_peer = None;
927− // ---------------- user actions ----------------
928− Event::ComposeChanged(s) => {
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());
938− model.settings.server_url = url;
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.
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) {
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),
983− writes.and(Command::event(Event::Connect)).and(render())
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 {
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
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)
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());
1030− match phonemic::try_parse_pubkey(&input) {
1031− Some(hex) if hex != model.my_pubkey => {
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;
1041− model.error = None;
1042− model.page = Page::Conversations;
1043− self.persist_contacts(model).and(render())
1046− model.error = Some("that's your own key".into());
1050− model.error = Some("invalid public key".into());
1055− Event::SetBlocked { peer, blocked } => {
1056− if let Some(c) = model.contacts.get_mut(&peer) {
1057− c.blocked = blocked;
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;
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)])
1075− self.persist_contacts(model).and(render())
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;
1089− model.error = None;
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),
1102− Event::SaveProfile {
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());
1118− if bio.chars().count() > protocol::MAX_BIO_LEN {
1119− model.error = Some("bio too long".into());
1122− if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
1123− model.error = Some("photo too large".into());
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());
1133− model.profile = OwnProfile {
1138− model.page = Page::Conversations;
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(),
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.
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
1159− .filter(|c| !c.blocked)
1160− .map(|c| c.pubkey.clone())
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.
1178− .find(|i| i.is_profile() && i.recipient == peer)
1180− Some(pending) => pending.envelope_json = Arc::clone(&payload_json),
1183− .push_back(OutboxItem::profile(peer, Arc::clone(&payload_json))),
1187− self.persist_profile(model),
1188− self.persist_outbox(model),
1190− .and(Command::event(Event::StartFlush))
1193− Event::SendText => self.send_text(model),
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();
1206− let Some(id) = model.identity() else {
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(),
1215− match Http::post(url).body_json(&body) {
1217− model.authenticating = true;
1218− let gen = model.next_auth_gen();
1220− .then_send(move |r| Event::ChallengeResult(gen, r))
1222− Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1224− .then_send(move |_| Event::AuthWatchdog(gen)),
1229− model.conn = ConnStatus::Offline;
1230− model.error = Some("could not build auth request".into());
1231− self.schedule_reconnect(model)
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();
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);
1249− let bytes = resp.take_body().unwrap_or_default();
1250− let Some(id) = model.identity() else {
1253− let Ok(c) = serde_json::from_slice::<ChallengeResp>(&bytes) else {
1254− model.conn = ConnStatus::Offline;
1255− return self.schedule_reconnect(model);
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);
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,
1272− match Http::post(url).body_json(&body) {
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))
1281− model.conn = ConnStatus::Offline;
1282− model.error = Some("could not build auth request".into());
1283− self.schedule_reconnect(model)
1287− Event::VerifyResult(gen, res) => {
1288− if gen != model.auth_gen {
1289− return Command::done();
1291− model.authenticating = false;
1292− let Ok(mut resp) = res else {
1293− model.conn = ConnStatus::Offline;
1294− return self.schedule_reconnect(model);
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))
1308− model.conn = ConnStatus::Offline;
1309− self.schedule_reconnect(model)
1314− // ---------------- poll ----------------
1316− // Exactly one poll loop at a time: each PollResult re-issues Poll,
1317− // so a second concurrent loop would double forever.
1319− return Command::done();
1321− let Some(token) = model.token.clone() else {
1322− return Command::event(Event::Authenticate);
1324− let url = format!("{}/poll", model.settings.server_url);
1325− let body = PollReq {
1326− cursor: model.cursor,
1328− match Http::post(url)
1329− .header("authorization", format!("Bearer {token}"))
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`.
1338− .then_send(move |r| Event::PollResult(gen, r))
1340− Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1342− .then_send(move |_| Event::PollWatchdog(gen)),
1346− model.error = Some("could not build poll request".into());
1347− self.backoff_poll(model)
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();
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)
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();
1377− model.polling = false;
1378− let Ok(mut resp) = res else {
1379− return self.backoff_poll(model);
1381− let status = u16::from(resp.status());
1383− model.token = None;
1384− return Command::event(Event::Authenticate);
1386− if !(200..300).contains(&status) {
1387− return self.backoff_poll(model);
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"
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);
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) {
1418− model.error = Some("relay sent a malformed poll response".into());
1419− return self.backoff_poll(model);
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.
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.
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)
1443− Command::event(Event::Poll)
1446− Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1448− .then_send(|_| Event::Poll),
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
1461− if gen != model.flush_gen || !model.flushing {
1462− return Command::done();
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
1468− model.abandon_flush();
1469− self.flush_next(model)
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();
1477− model.abandon_auth();
1478− Command::event(Event::Authenticate)
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();
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());
1494− model.token = None;
1495− // The batch stays exactly as it is — re-auth, then re-send it.
1496− return Command::event(Event::Authenticate);
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.
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.
1511− .header("retry-after")
1512− .map(|v| v.last().as_str())
1513− .and_then(|v| v.trim().parse::<u64>().ok())
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
1523− let ms = backoff_ms(model.send_retries);
1524− model.send_retries = model.send_retries.saturating_add(1);
1525− Duration::from_millis(ms)
1528− Duration::from_millis(
1529− secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
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
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)
1543− .then_send(|_| Event::StartFlush)
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.
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.
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
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);
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.
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();
1585− return self.persist_outbox(model).and(self.flush_next(model));
1587− // A transient failure: a 5xx, a rate limit, a relay restarting.
1588− self.retry_batch(model, batch)
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();
1595− model.flushing = false;
1596− let batch = model.in_flight.take();
1597− self.retry_batch(model, batch)
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",
1610− let conn = match model.conn {
1611− ConnStatus::Offline => "offline",
1612− ConnStatus::Connecting => "connecting",
1613− ConnStatus::Online => "online",
1616− let active_peer = model.active_peer.clone().unwrap_or_default();
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;
1627− let mut contacts: Vec<ContactVM> = model
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 {
1636− c.photo.clone().unwrap_or_default()
1639− pubkey: c.pubkey.clone(),
1641− ob: hex_to_ob(&c.pubkey),
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),
1649− contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
1651− let active_contact = model.contacts.get(&active_peer);
1652− let messages: Vec<MessageVM> = model
1657− .map(|s| MessageVM {
1659− body: s.body.clone(),
1661− outgoing: s.outgoing,
1662− delivered: s.delivered,
1666− .unwrap_or_default();
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(),
1681− active_peer: active_peer.clone(),
1682− active_peer_name: active_contact.map_or_else(
1683− || trunc_ob(&active_peer),
1686− active_peer_ob: hex_to_ob(&active_peer),
1687− active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
1689− error: model.error.clone().unwrap_or_default(),
1695− /// One of the five startup kv loads landed. Fires `Connect` on the last one.
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();
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.
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"
1720− Command::event(Event::Connect)
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.
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());
1738− fn persist_contacts(&self, model: &mut Model) -> Command<Effect, Event> {
1739− if self.refuse_write(model) {
1740− return Command::done();
1742− let list: Vec<Contact> = model.contacts.values().cloned().collect();
1743− KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
1746− fn persist_profile(&self, model: &mut Model) -> Command<Effect, Event> {
1747− if self.refuse_write(model) {
1748− return Command::done();
1750− KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved)
1753− fn persist_outbox(&self, model: &mut Model) -> Command<Effect, Event> {
1754− if self.refuse_write(model) {
1755− return Command::done();
1757− let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
1758− KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
1761− fn persist_messages(&self, model: &mut Model, peer: &str) -> Command<Effect, Event> {
1762− if self.refuse_write(model) {
1763− return Command::done();
1765− let list = model.messages.get(peer).cloned().unwrap_or_default();
1766− KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
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();
1776− KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
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))
1784− .then_send(|_| Event::Authenticate)
1788− /// The poll loop stalled — retry after a backoff, and stop claiming to be online.
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))
1801− .then_send(|_| Event::Poll)
1805− fn send_text(&self, model: &mut Model) -> Command<Effect, Event> {
1806− let Some(peer) = model.active_peer.clone() else {
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());
1820− let body = model.compose.trim().to_string();
1821− if body.is_empty() {
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
1829− if body.len() > protocol::MAX_BODY_LEN {
1830− model.error = Some("message too long".into());
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());
1847− model.compose.clear();
1849− let id = uuid::Uuid::new_v4().to_string();
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*.
1859− body: body.clone(),
1865− trim_history(convo);
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.
1874− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1877− let text = Payload::Text { id, body };
1878− model.outbox.push_back(OutboxItem::new(
1880− Arc::new(protocol::serialize_payload(&text, ts)),
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.)
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(),
1894− model.outbox.push_back(OutboxItem::profile(
1896− Arc::new(protocol::serialize_payload(&profile, ts)),
1901− self.persist_messages(model, &peer),
1902− self.persist_contacts(model),
1903− self.persist_outbox(model),
1905− .and(Command::event(Event::StartFlush))
1909− /// Encrypt and send a batch from the head of the outbox, discarding any
1910− /// unsendable items in the way.
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.
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.
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();
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();
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();
1950− let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
1951− return Command::done();
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);
1963− while messages.len() < MAX_SEND_BATCH {
1964− let Some(item) = model.outbox.get(messages.len()) else {
1967− if recipient.as_ref().is_some_and(|r| r != &item.recipient) {
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)
1980− model.outbox.remove(messages.len());
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 {
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,
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.
2004− self.persist_outbox(model).and(render())
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
2014− for item in model.outbox.iter_mut().take(messages.len()) {
2015− if item.first_attempt == 0 {
2016− item.first_attempt = now;
2020− let persist = if dirty {
2021− self.persist_outbox(model)
2026− model.flushing = true;
2027− let gen = model.next_flush_gen();
2028− model.in_flight = Some(InFlight {
2030− count: messages.len(),
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 })
2038− Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
2040− Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2042− .then_send(move |_| Event::FlushWatchdog(gen)),
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())
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
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 {
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) {
2082− kept.push_back(item);
2084− model.outbox = kept;
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());
2092− model.error = Some(format!(
2093− "gave up sending {expired} message(s) to {}",
2094− trunc_ob(&batch.recipient)
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))
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();
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;
2128− let Some(id) = model.identity() else {
2129− return Command::done();
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();
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 {
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
2170− if events_stored == 0 {
2171− model.cursor = prev_cursor;
2173− model.error = Some("relay sent too many poll events".into());
2176− events_processed += 1;
2177− match budget.checked_sub(ev.encrypted_blob.len() / 2) {
2178− Some(left) => budget = left,
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
2188− if events_stored == 0 {
2189− model.cursor = prev_cursor;
2191− model.error = Some("relay sent an oversized poll page".into());
2195− let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
2198− let Ok(dec) = crate::crypto::decrypt(&id, &blob) else {
2201− let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
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;
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 {
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
2227− if model.contacts.get(&sender).is_some_and(|c| c.blocked) {
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.
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.
2251− if model.contacts.len() >= MAX_CONTACTS {
2254− model.contacts.insert(
2256− Contact::new(sender.clone(), String::new(), ts),
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(),
2266− model.outbox.push_back(OutboxItem::profile(
2268− Arc::new(protocol::serialize_payload(&profile, ts)),
2270− outbox_dirty = true;
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.
2281− .entry(sender.clone())
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()));
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.
2297− id: msg_id.clone(),
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
2320− .filter(|id| !convo_ids.contains(*id))
2324− for id in to_remove {
2325− id_set.remove(&id);
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);
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
2348− let entry = pending_acks.entry(sender.clone()).or_default();
2352− .entry(sender.clone())
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 {
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.
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;
2387− if let Some(b) = bio {
2390− if let Some(p) = photo {
2393− c.last_profile_ts = ts.min(now);
2394− contacts_dirty = true;
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 {
2408− .is_some_and(|c| pending.ts < c.last_ack_ts);
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;
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;
2431− touched_convos.insert(sender.clone());
2436− // Queue delivery acks, in batches the recipient will actually accept.
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(),
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(
2455− Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2458− outbox_dirty = true;
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));
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));
2470− if contacts_dirty {
2471− cmds.push(self.persist_contacts(model));
2474− cmds.push(self.persist_outbox(model));
2476− cmds.push(Command::event(Event::StartFlush));
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()
2491−fn backoff_ms(retries: u32) -> u64 {
2492− (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
2495−/// Insert a message into a conversation held in `ts` order.
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.
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);
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);
2519−/// Decode a kv `get` result into a typed value.
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.
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>, ()> {
2533− Ok(Some(bytes)) => serde_json::from_slice(&bytes)
2536− Ok(None) => Ok(None),
2544− use crate::crypto::Identity;
2545− use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
2547− fn with_identity() -> Model {
2548− let id = Identity::from_seed(&[3u8; 32]).unwrap();
2550− secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
2551− my_pubkey: id.public_key_hex(),
2552− ..Default::default()
2556− fn peer_hex(seed: u8) -> String {
2557− Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
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);
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);
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,
2586− &recipient.public_key(),
2591− events: vec![PollEvent {
2592− encrypted_blob: hex::encode(blob),
2598− fn text(id: &str, body: &str) -> Payload {
2600− id: id.to_string(),
2601− body: body.to_string(),
2605− fn queued(recipient: &str, body: &str) -> OutboxItem {
2607− recipient.to_string(),
2608− Arc::new(protocol::serialize_payload(&text(body, body), 1)),
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)]
2618− messages: Vec<Envelope>,
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)
2625− .map(|b| b.messages)
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)
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)
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
2652− fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
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)
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);
2673− // -----------------------------------------------------------------------
2674− // outbox: retry budget and TTL
2675− // -----------------------------------------------------------------------
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.
2680− fn an_undeliverable_item_is_dropped_once_it_runs_out_of_retries() {
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"));
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");
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);
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");
2704− sent_batches(&mut cmd).len(),
2706− "the message behind it is sent immediately, not left for the next flush"
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}");
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.
2716− fn an_item_older_than_the_ttl_is_dropped_on_its_next_failure() {
2718− let mut m = with_identity();
2719− m.token = Some("t".into());
2720− let peer = peer_hex(9);
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);
2727− send_and_answer(&app, &mut m, 503);
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")));
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".
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);
2742− !item.is_expired(now_ms()),
2743− "an unsent item must not age out against the epoch"
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.
2750− fn the_first_attempt_stamp_is_persisted_when_the_item_goes_out() {
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();
2757− let mut cmd = app.update(Event::StartFlush, &mut m);
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))
2765− assert!(wrote_outbox, "the stamped outbox is persisted");
2768− /// A transient failure below the budget changes nothing but the counter — the
2769− /// message stays queued and goes out on the next flush.
2771− fn a_transient_failure_within_budget_keeps_the_message() {
2773− let mut m = with_identity();
2774− m.token = Some("t".into());
2775− m.outbox.push_back(queued(&peer_hex(9), "hi"));
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");
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());
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.
2792− fn a_401_does_not_spend_the_retry_budget() {
2794− let mut m = with_identity();
2795− m.token = Some("t".into());
2796− m.outbox.push_back(queued(&peer_hex(9), "hi"));
2798− send_and_answer(&app, &mut m, 401);
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");
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.
2810− fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
2812− let mut m = with_identity();
2813− m.token = Some("t".into());
2814− m.outbox.push_back(queued(&peer_hex(9), "hi"));
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"))),
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");
2826− scheduled_delays(&mut cmd),
2828− "and we wait exactly as long as the relay asked"
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.
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.
2839− fn a_429_without_header_uses_send_side_backoff() {
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;
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);
2851− assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2853− scheduled_delays(&mut cmd),
2854− vec![backoff_ms(0)],
2855− "the first send-side backoff"
2857− assert_eq!(m.send_retries, 1, "and the send loop's own counter is spent");
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);
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.
2872− fn a_429_pauses_flush_until_the_delay_elapses() {
2874− let mut m = with_identity();
2875− m.token = Some("t".into());
2876− m.outbox.push_back(queued(&peer_hex(9), "hi"));
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"))),
2884− m.flush_paused_until > now_ms(),
2885− "the relay's wait is latched on the model, not just on a timer"
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);
2892− sent_batches(&mut cmd).is_empty(),
2893− "nothing goes out while the relay has asked us to wait"
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");
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);
2903− sent_batches(&mut cmd).len(),
2905− "and once the wait is over the batch goes out"
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.
2913− fn a_successful_send_clears_the_send_side_backoff() {
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"));
2920− send_and_answer(&app, &mut m, 200);
2922− assert_eq!(m.send_retries, 0, "the relay is taking our sends again");
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.
2928− fn an_absurd_retry_after_is_clamped() {
2930− let mut m = with_identity();
2931− m.token = Some("t".into());
2932− m.outbox.push_back(queued(&peer_hex(9), "hi"));
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"))),
2941− scheduled_delays(&mut cmd),
2942− vec![MAX_RETRY_AFTER_MS],
2943− "we wait, but only as long as we are willing to"
2947− // -----------------------------------------------------------------------
2948− // outbox: batching
2949− // -----------------------------------------------------------------------
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.
2954− fn consecutive_items_for_one_recipient_go_out_in_a_single_request() {
2956− let mut m = with_identity();
2957− m.token = Some("t".into());
2958− let peer = peer_hex(9);
2960− m.outbox.push_back(queued(&peer, &format!("m{i}")));
2963− let mut cmd = app.update(Event::StartFlush, &mut m);
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));
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());
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.
2979− fn a_batch_stops_at_the_first_recipient_change() {
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"));
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));
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);
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.
3006− fn a_batch_is_capped_at_the_relays_limit() {
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}")));
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);
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");
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.
3027− fn a_batch_is_capped_by_body_size_as_well_as_count() {
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)
3040− .take(protocol::MAX_BODY_LEN)
3042− for i in 0..MAX_SEND_BATCH {
3043− m.outbox.push_back(OutboxItem::new(
3045− Arc::new(protocol::serialize_payload(&text(&format!("m{i}"), &big), 1)),
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();
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");
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.
3064− fn a_413_halves_the_batch_budget_and_retries_smaller() {
3066− let mut m = with_identity();
3067− m.token = Some("t".into());
3068− let peer = peer_hex(9);
3070− m.outbox.push_back(queued(&peer, &format!("m{i}")));
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();
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);
3084− m.send_batch_budget,
3085− Some(sent_bytes / 2),
3086− "the budget is halved against what actually failed, not against the constant"
3088− assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
3090− m.outbox.iter().all(|i| i.retries == 0),
3091− "and the items are not charged for the relay being smaller than we guessed"
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");
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");
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"
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.
3116− fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
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"));
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");
3128− m.send_batch_budget.is_none(),
3129− "an unsplittable batch teaches us nothing about the cap"
3132− send_and_answer(&app, &mut m, 413);
3133− assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
3135− m.error.is_some_and(|e| e.contains("gave up sending")),
3136− "and the user is told, rather than the message quietly vanishing"
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.
3143− fn changing_relay_forgets_the_learned_batch_budget() {
3145− let mut m = with_identity();
3146− m.send_batch_budget = Some(1024);
3148− let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
3150− assert!(m.send_batch_budget.is_none());
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.
3156− fn an_unsendable_head_is_dropped_and_the_batch_behind_it_still_goes_out() {
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"));
3165− let mut cmd = app.update(Event::StartFlush, &mut m);
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")));
3173− // -----------------------------------------------------------------------
3174− // outbox: profile supersede
3175− // -----------------------------------------------------------------------
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.
3180− fn repeated_profile_edits_supersede_rather_than_pile_up() {
3182− let mut m = with_identity();
3183− let a = peer_hex(9);
3184− let b = peer_hex(11);
3185− for peer in [&a, &b] {
3187− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3190− for name in ["One", "Two", "Three"] {
3191− let _ = app.update(
3192− Event::SaveProfile {
3193− display_name: name.into(),
3194− bio: String::new(),
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");
3209− _ => panic!("expected a profile"),
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.
3218− fn a_profile_already_in_flight_is_not_rewritten_under_the_send() {
3220− let mut m = with_identity();
3221− m.token = Some("t".into());
3222− let peer = peer_hex(9);
3224− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3226− let _ = app.update(
3227− Event::SaveProfile {
3228− display_name: "Old".into(),
3229− bio: String::new(),
3234− // It goes out and is still in flight.
3235− let _ = app.update(Event::StartFlush, &mut m);
3236− assert!(m.flushing);
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(),
3248− assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
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"
3258− _ => panic!("expected a profile"),
3262− // -----------------------------------------------------------------------
3263− // in-flight bookkeeping
3264− // -----------------------------------------------------------------------
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.
3270− fn blocking_the_peer_mid_send_does_not_pop_someone_elses_messages() {
3272− let mut m = with_identity();
3273− m.token = Some("t".into());
3274− let enemy = peer_hex(9);
3275− let friend = peer_hex(11);
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"));
3282− let _ = app.update(Event::StartFlush, &mut m);
3283− assert_eq!(m.in_flight.as_ref().unwrap().count, 2);
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(),
3293− assert_eq!(m.outbox.len(), 1);
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);
3301− // -----------------------------------------------------------------------
3303− // -----------------------------------------------------------------------
3305− /// Blocking only silences a peer; the entry stayed in the list forever.
3307− fn deleting_a_contact_forgets_the_contact_the_history_and_the_queue() {
3309− let mut m = with_identity();
3310− let peer = peer_hex(9);
3311− let other = peer_hex(11);
3312− for p in [&peer, &other] {
3314− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3317− vec![StoredMessage {
3325− m.outbox.push_back(queued(p, "queued"));
3327− m.active_peer = Some(peer.clone());
3328− m.page = Page::Chat;
3330− let mut cmd = app.update(Event::DeleteContact { peer: peer.clone() }, &mut m);
3332− assert!(!m.contacts.contains_key(&peer));
3333− assert!(!m.messages.contains_key(&peer));
3335− m.outbox.iter().all(|i| i.recipient != peer),
3336− "nothing more goes to a peer we have forgotten"
3338− assert_eq!(m.page, Page::Conversations, "the open chat is closed");
3339− assert!(m.active_peer.is_none());
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);
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)))
3352− assert!(deleted, "the messages:<peer> key is removed");
3355− /// Deleting a peer we are not looking at must not throw the user out of the
3356− /// chat they *are* looking at.
3358− fn deleting_another_contact_leaves_the_open_chat_alone() {
3360− let mut m = with_identity();
3361− let open = peer_hex(9);
3362− let doomed = peer_hex(11);
3363− for p in [&open, &doomed] {
3365− .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3367− m.active_peer = Some(open.clone());
3368− m.page = Page::Chat;
3370− let _ = app.update(Event::DeleteContact { peer: doomed }, &mut m);
3372− assert_eq!(m.page, Page::Chat);
3373− assert_eq!(m.active_peer.as_deref(), Some(open.as_str()));
3376− // -----------------------------------------------------------------------
3377− // watermark clamping
3378− // -----------------------------------------------------------------------
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.
3385− fn a_far_future_ack_cannot_suppress_the_acks_that_follow_it() {
3387− let mut m = with_identity();
3388− let peer = peer_hex(9);
3390− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3393− vec![StoredMessage {
3402− // The poisoned ack: acks nothing we sent, but sets the watermark.
3403− let ack = Payload::DeliveryAck {
3404− ack_ids: vec!["nothing".into()],
3406− let page = page_from(9, &m, &ack, i64::MAX);
3407− let _ = app.ingest_poll(&mut m, page);
3409− m.contacts[&peer].last_ack_ts <= now_ms(),
3410− "the watermark cannot be pushed into the future"
3413− // A perfectly ordinary ack, sent now. It must still land.
3414− let ack = Payload::DeliveryAck {
3415− ack_ids: vec!["m1".into()],
3417− let page = page_from(9, &m, &ack, now_ms());
3418− let _ = app.ingest_poll(&mut m, page);
3420− m.messages[&peer][0].delivered,
3421− "an honest ack after a far-future one must still be honoured"
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.
3429− fn a_far_future_profile_cannot_freeze_the_peers_own_profile() {
3431− let mut m = with_identity();
3432− let peer = peer_hex(9);
3434− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3436− let profile = |name: &str| Payload::Profile {
3437− display_name: Some(name.to_string()),
3438− bio: Some(String::new()),
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());
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);
3451− m.contacts[&peer].display_name, "Now",
3452− "a later, honest profile must still apply"
3456− // -----------------------------------------------------------------------
3457− // conversation ordering
3458− // -----------------------------------------------------------------------
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.
3464− fn messages_are_inserted_in_timestamp_order() {
3466− let mut m = with_identity();
3467− let peer = peer_hex(9);
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);
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);
3479− let convo = &m.messages[&peer];
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<_>>()
3485− // Ties keep arrival order, exactly as the stable sort used to give.
3486− let tied: Vec<&str> = convo
3488− .filter(|s| s.ts == 200)
3489− .map(|s| s.id.as_str())
3491− assert_eq!(tied, vec!["m2", "m3"]);
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.
3497− fn a_blocked_senders_message_is_neither_stored_nor_acked() {
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);
3505− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3506− let _ = app.ingest_poll(&mut m, page);
3509− m.messages.get(&peer).is_none_or(Vec::is_empty),
3510− "a blocked peer's message must not be stored"
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());
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) {
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);
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);
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.
3545− fn a_far_future_timestamp_is_clamped_to_now() {
3547− let mut m = with_identity();
3548− let peer = peer_hex(9);
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);
3554− let ts = m.messages[&peer][0].ts;
3556− ts <= now_ms() + MAX_FUTURE_SKEW_MS,
3557− "ts {ts} must be clamped into the skew window"
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);
3564− /// Honest clock skew inside the tolerance is preserved as-is.
3566− fn a_slightly_future_timestamp_is_kept() {
3568− let mut m = with_identity();
3569− let peer = peer_hex(9);
3570− let ts = now_ms() + 5_000;
3572− let page = page_from(9, &m, &text("m1", "hi"), ts);
3573− let _ = app.ingest_poll(&mut m, page);
3575− assert_eq!(m.messages[&peer][0].ts, ts);
3579− fn message_history_is_capped_at_the_cutoff() {
3581− let mut m = with_identity();
3582− let peer = peer_hex(9);
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}"),
3595− let page = page_from(9, &m, &text("new", "newest"), now_ms());
3596− let _ = app.ingest_poll(&mut m, page);
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");
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.
3607− fn a_send_to_an_unusable_key_reports_an_error() {
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
3613− .push_back(OutboxItem::new(bad, Arc::new("{}".into())));
3615− let _ = app.update(Event::StartFlush, &mut m);
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}");
3624− fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
3626− let mut m = with_identity();
3627− let bad = hex::encode([0xabu8; 32]);
3629− let _ = app.update(
3630− Event::AddContact {
3631− input: bad.clone(),
3632− nickname: "Mallory".into(),
3637− assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
3638− assert!(m.error.is_some());
3641− /// A dropped kv write silently loses data; it must not be swallowed.
3643− fn a_failed_save_surfaces_an_error() {
3645− let mut m = with_identity();
3647− let _ = app.update(
3648− Event::Saved(Err(crux_kv::error::KeyValueError::Io {
3649− message: "no space left on device".into(),
3653− assert!(m.error.is_some(), "a failed write is reported");
3655− // A successful write stays quiet.
3657− let _ = app.update(Event::Saved(Ok(None)), &mut m);
3658− assert!(m.error.is_none());
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"), "");
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 `[`.
3677− fn server_host_handles_an_ipv6_literal() {
3678− assert_eq!(server_host("http://[::1]:8080"), "[::1]");
3680− server_host("https://[2001:DB8::1]/poll"),
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);
3694− fn add_contact_accepts_hex_and_rejects_self() {
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(),
3705− assert!(m.contacts.contains_key(&peer));
3706− assert_eq!(m.contacts[&peer].nickname, "Bob");
3708− // adding own key is rejected with an error
3709− let mine = m.my_pubkey.clone();
3710− let _ = app.update(
3711− Event::AddContact {
3713− nickname: String::new(),
3717− assert!(m.error.is_some());
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.
3724− fn add_contact_clears_a_stale_error_on_success() {
3726− let mut m = with_identity();
3727− m.error = Some("invalid public key".into());
3729− let peer = peer_hex(9);
3730− let _ = app.update(
3731− Event::AddContact {
3732− input: peer.clone(),
3733− nickname: "Bob".into(),
3738− assert!(m.error.is_none(), "a successful add clears the stale error");
3739− assert!(m.contacts.contains_key(&peer));
3743− fn send_text_enqueues_message_profile_and_outbox() {
3745− let mut m = with_identity();
3746− let peer = peer_hex(9);
3747− m.active_peer = Some(peer.clone());
3748− m.compose = "hello".into();
3750− let _ = app.update(Event::SendText, &mut m);
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);
3766− fn send_text_without_peer_or_empty_is_noop() {
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());
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());
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.
3784− fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
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));
3792− let new_peer = peer_hex(9);
3793− m.active_peer = Some(new_peer.clone());
3794− m.compose = "hello new friend".into();
3796− let _ = app.update(Event::SendText, &mut m);
3799− m.compose, "hello new friend",
3800− "compose must not be cleared on rejection"
3803− m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3804− "no orphan conversation must be created"
3806− assert!(m.outbox.is_empty(), "nothing must be queued");
3807− assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3809− m.error.as_ref().is_some_and(|e| e.contains("full")),
3810− "the user is told why"
3815− fn set_blocked_toggles_and_view_reflects_it() {
3817− let mut m = with_identity();
3818− let peer = peer_hex(9);
3820− .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
3821− let _ = app.update(
3822− Event::SetBlocked {
3823− peer: peer.clone(),
3828− assert!(m.contacts[&peer].blocked);
3829− let vm = app.view(&m);
3830− assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
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.
3836− fn unblocking_clears_the_stale_send_error() {
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());
3845− let _ = app.update(
3846− Event::SetBlocked {
3847− peer: peer.clone(),
3853− assert!(!m.contacts[&peer].blocked);
3854− assert!(m.error.is_none(), "unblocking clears the stale error");
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
3862− fn blocking_a_peer_drops_what_is_already_queued_for_them() {
3864− let mut m = with_identity();
3865− let enemy = peer_hex(9);
3866− let friend = peer_hex(11);
3868− .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
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(
3874− Arc::new(protocol::serialize_payload(
3875− &Payload::DeliveryAck {
3876− ack_ids: vec!["m1".into()],
3883− let _ = app.update(
3884− Event::SetBlocked {
3885− peer: enemy.clone(),
3891− let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
3894− vec![friend.as_str()],
3895− "nothing more goes to a blocked peer — and everyone else is untouched"
3900− fn normalize_server_url_requires_an_http_scheme() {
3901− // The real parser canonicalizes: scheme and host are lowercased.
3903− normalize_server_url(" https://Relay.Example.com/ "),
3904− Some("https://relay.example.com".to_string())
3907− normalize_server_url("HTTP://localhost:8080///"),
3908− Some("http://localhost:8080".to_string())
3911− normalize_server_url("http://[::1]:8080"),
3912− Some("http://[::1]:8080".to_string())
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);
3924− /// The hand-rolled validator this replaces was not the parser it guarded:
3925− /// it waved these through, and `crux_http` unwraps the parse.
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);
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.
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);
3943− fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
3945− let mut m = with_identity();
3946− let original = m.settings.server_url.clone();
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());
3952− let _ = app.update(
3953− Event::SetServerUrl(" https://relay.example.com/ ".into()),
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);
3962− /// The regression that stalled the outbox: `flushing` stayed `true` forever
3963− /// after the first successful send, so nothing was ever sent again.
3965− fn send_result_ok_clears_flushing() {
3967− let mut m = with_identity();
3968− m.token = Some("t".into());
3970− .push_back(OutboxItem::new(peer_hex(9), Arc::new("{}".into())));
3972− let _ = app.update(Event::StartFlush, &mut m);
3973− assert!(m.flushing);
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);
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");
3983− fn flush_next_resumes_after_a_successful_send() {
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] {
3990− .push_back(OutboxItem::new(peer_hex(seed), Arc::new("{}".into())));
3992− // First send goes out and marks the model as flushing.
3993− let _ = app.update(Event::StartFlush, &mut m);
3994− assert!(m.flushing);
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");
4004− fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
4006− let mut m = with_identity();
4007− m.token = Some("t".into());
4009− let _ = app.update(Event::Poll, &mut m);
4010− assert!(m.polling, "the first Poll issues a request");
4011− let gen = m.poll_gen;
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");
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);
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)),
4029− assert!(!m.polling);
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.
4036− fn switching_relays_resets_the_cursor() {
4038− let mut m = with_identity();
4039− m.cursor = now_ms();
4040− m.token = Some("t".into());
4041− m.conn = ConnStatus::Online;
4043− let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
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);
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.
4056− fn switching_relays_retires_the_poll_still_in_flight_at_the_old_one() {
4058− let mut m = with_identity();
4059− m.token = Some("t".into());
4060− m.conn = ConnStatus::Online;
4062− let _ = app.update(Event::Poll, &mut m);
4063− let old_relay_poll = m.poll_gen;
4064− assert!(m.polling);
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");
4069− // It resolves after the switch, carrying the old relay's cursor.
4070− let body = serde_json::to_vec(&serde_json::json!({
4072− "cursor": 1_700_000_000_000i64,
4075− let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4076− let _ = app.update(Event::PollResult(old_relay_poll, Ok(resp)), &mut m);
4080− "the new relay must not be handed a cursor from a sequence it never issued"
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.
4087− fn a_corrupt_persisted_server_url_falls_back_to_the_default() {
4089− let mut m = with_identity();
4090− m.loads_pending = STARTUP_LOADS;
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);
4095− assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4097− normalize_server_url(&m.settings.server_url).is_some(),
4098− "whatever we end up with must survive the parser"
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.
4106− fn an_old_settings_blob_still_loads() {
4108− let mut m = with_identity();
4109− m.loads_pending = STARTUP_LOADS;
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);
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));
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.
4125− fn connect_waits_for_every_startup_load() {
4127− let mut m = with_identity();
4128− m.loads_pending = STARTUP_LOADS;
4130− let connects = |cmd: &mut Command<Effect, Event>| {
4132− .filter(|e| matches!(e, Event::Connect))
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)),
4146− let mut cmd = app.update(ev, &mut m);
4149− STARTUP_LOADS - 1 - i as u8,
4150− "each load ticks the counter down"
4152− assert_eq!(connects(&mut cmd), 0, "and none of them connects");
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");
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);
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.
4171− /// A read failure is not an empty key. Nothing may be written until a launch
4172− /// reads the key successfully.
4174− fn a_failed_read_never_writes_over_what_it_could_not_read() {
4176− let mut m = with_identity();
4177− m.loads_pending = STARTUP_LOADS;
4179− let connects = |cmd: &mut Command<Effect, Event>| {
4181− .filter(|e| matches!(e, Event::Connect))
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>| {
4188− .filter_map(|e| match e {
4189− Effect::KeyValue(req) => match &req.operation {
4190− crux_kv::KeyValueOperation::Set { key, .. } => Some(key.clone()),
4195− .collect::<Vec<_>>()
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(),
4211− assert!(m.kv_load_failed, "the model knows it has a hole in it");
4212− assert!(m.error.is_some(), "and says so");
4214− connects(&mut cmd),
4216− "the last load does not connect: a poll would ack mail we cannot store"
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(),
4228− kv_writes(&mut cmd).is_empty(),
4229− "contacts are not written over an unread model"
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())
4238− let mut cmd = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4240− kv_writes(&mut cmd).is_empty(),
4241− "an empty poll writes nothing — not the outbox, not the cursor"
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);
4249− "a fresh round of loads gets to answer for itself"
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.
4257− fn authentication_does_not_stack() {
4259− let mut m = with_identity();
4261− let _ = app.update(Event::Authenticate, &mut m);
4262− assert!(m.authenticating, "the first one goes out");
4263− assert_eq!(m.conn, ConnStatus::Connecting);
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");
4270− // Any terminal outcome releases the guard.
4271− let _ = app.update(
4272− Event::ChallengeResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4275− assert!(!m.authenticating);
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)),
4283− assert!(!m.authenticating);
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
4292− fn an_auth_round_trip_superseded_by_connect_cannot_land() {
4294− let mut m = with_identity();
4296− let _ = app.update(Event::Authenticate, &mut m);
4297− let superseded = m.auth_gen;
4298− assert!(m.authenticating);
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");
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);
4312− "the live attempt still holds the guard — a stale leg must not release it"
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);
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);
4330− /// Blocking is meant to cut a peer off in *both* directions.
4332− fn a_blocked_peer_cannot_update_their_profile_or_ack_our_messages() {
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);
4342− vec![StoredMessage {
4351− let profile = Payload::Profile {
4352− display_name: Some("Mallory".into()),
4353− bio: Some("spam".into()),
4354− photo: Some("aGk=".into()),
4356− let page = page_from(9, &m, &profile, now_ms());
4357− let _ = app.ingest_poll(&mut m, page);
4359− m.contacts[&peer].display_name, "Bob",
4360− "a blocked peer cannot rewrite their entry in our contact list"
4363− let ack = Payload::DeliveryAck {
4364− ack_ids: vec!["mine".into()],
4366− let page = page_from(9, &m, &ack, now_ms());
4367− let _ = app.ingest_poll(&mut m, page);
4369− !m.messages[&peer][0].delivered,
4370− "nor tell us they read what we sent before the block"
4374− /// Blocking someone and then updating our profile must not hand them our new
4375− /// display name, avatar, and a liveness signal.
4377− fn save_profile_does_not_broadcast_to_blocked_contacts() {
4379− let mut m = with_identity();
4380− let friend = peer_hex(9);
4381− let enemy = peer_hex(11);
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);
4388− let _ = app.update(
4389− Event::SaveProfile {
4390− display_name: "Alice".into(),
4391− bio: String::new(),
4397− let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
4398− assert_eq!(recipients, vec![friend.as_str()]);
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.
4405− fn contacts_from_strangers_are_capped() {
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));
4412− assert_eq!(m.contacts.len(), MAX_CONTACTS);
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);
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");
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.
4428− fn a_profile_from_a_stranger_does_not_create_a_contact() {
4430− let mut m = with_identity();
4431− let peer = peer_hex(9);
4433− let profile = Payload::Profile {
4434− display_name: Some("Mallory".into()),
4435− bio: Some(String::new()),
4436− photo: Some("aGk=".into()),
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");
4442− // But a profile from someone we *do* know still applies.
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");
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.
4457− fn a_message_from_ourselves_is_dropped() {
4459− let mut m = with_identity();
4460− let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4462− let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
4463− let blob = crate::crypto::encrypt(
4464− &mut rand_core::OsRng,
4470− let page = PollResp {
4471− events: vec![PollEvent {
4472− encrypted_blob: hex::encode(blob),
4477− let _ = app.ingest_poll(&mut m, page);
4480− !m.contacts.contains_key(&m.my_pubkey),
4481− "we are not our own contact"
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");
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.
4491− fn the_watchdog_unwedges_a_poll_that_never_resolved() {
4493− let mut m = with_identity();
4494− m.token = Some("t".into());
4496− let _ = app.update(Event::Poll, &mut m);
4497− assert!(m.polling);
4498− let wedged = m.poll_gen;
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");
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.
4511− fn a_stale_watchdog_leaves_the_current_poll_alone() {
4513− let mut m = with_identity();
4514− m.token = Some("t".into());
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);
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");
4529− /// The regression the generation exists for.
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.
4538− fn a_poll_abandoned_by_the_watchdog_cannot_fork_the_loop_when_it_lands() {
4540− let mut m = with_identity();
4541− m.token = Some("t".into());
4543− let _ = app.update(Event::Poll, &mut m);
4544− let abandoned = m.poll_gen;
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);
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);
4555− assert!(m.polling, "the live poll still holds the guard");
4556− assert_eq!(m.poll_gen, live);
4558− cmd.events().count(),
4560− "and the corpse chains nothing — no second loop"
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.
4567− fn an_abandoned_poll_page_is_not_ingested() {
4569− let mut m = with_identity();
4570− m.token = Some("t".into());
4571− let peer = peer_hex(9);
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}],
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);
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");
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.
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.
4600− fn photos_are_withheld_on_the_chat_page_and_nowhere_else() {
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);
4610− let photo_of = |vm: &ViewModel, k: &str| {
4613− .find(|c| c.pubkey == k)
4614− .map(|c| c.photo.clone())
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=");
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());
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.
4638− fn acks_are_batched_so_the_peer_can_actually_parse_them() {
4640− let mut m = with_identity();
4641− let peer = peer_hex(9);
4643− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
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)
4651− let json = protocol::serialize_payload(&text(&format!("m{i}"), "hi"), now_ms());
4652− let blob = crate::crypto::encrypt(
4653− &mut rand_core::OsRng,
4660− encrypted_blob: hex::encode(blob),
4664− let _ = app.ingest_poll(&mut m, PollResp { events, cursor: 1 });
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");
4671− // And every batch really does survive the recipient's parser.
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();
4682− _ => panic!("expected a delivery.ack"),
4685− assert_eq!(acked, count, "and between them they ack the whole page");
4689− fn view_exposes_identity_and_ob() {
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);
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.
4702− fn a_replayed_delivery_ack_is_ignored() {
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);
4711− vec![StoredMessage {
4720− // An ack with ts = 50 (< 100) is stale → ignored.
4721− let ack = Payload::DeliveryAck {
4722− ack_ids: vec!["m1".into()],
4724− let page = page_from(9, &m, &ack, 50);
4725− let _ = app.ingest_poll(&mut m, page);
4727− !m.messages[&peer][0].delivered,
4728− "a stale ack must not mark the message delivered"
4731− m.contacts[&peer].last_ack_ts, 100,
4732− "last_ack_ts must not regress"
4735− // An ack with ts = 200 (> 100) passes → message is marked delivered.
4736− let ack = Payload::DeliveryAck {
4737− ack_ids: vec!["m1".into()],
4739− let page = page_from(9, &m, &ack, 200);
4740− let _ = app.ingest_poll(&mut m, page);
4742− m.messages[&peer][0].delivered,
4743− "a fresh ack must mark the message delivered"
4746− m.contacts[&peer].last_ack_ts, 200,
4747− "last_ack_ts advances to the fresh ack's ts"
4751− // -----------------------------------------------------------------------
4752− // cursor restoration on budget exhaustion
4753− // -----------------------------------------------------------------------
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
4761− fn a_budget_exhausted_mid_page_restores_the_cursor() {
4763− let mut m = with_identity();
4764− let peer = peer_hex(9);
4766− .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
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 {
4778− .map(|_| PollEvent {
4779− encrypted_blob: big_hex.clone(),
4784− let _ = app.ingest_poll(&mut m, page);
4788− "the cursor must be restored — the page was not fully processed"
4791− m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4795− m.messages.get(&peer).is_none_or(Vec::is_empty),
4796− "nothing was stored from the oversized page"
4800− // -----------------------------------------------------------------------
4801− // send_text block check
4802− // -----------------------------------------------------------------------
4804− // -----------------------------------------------------------------------
4805− // send_text body length check
4806− // -----------------------------------------------------------------------
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.
4812− fn send_text_to_a_blocked_peer_is_refused() {
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();
4822− let _ = app.update(Event::SendText, &mut m);
4825− m.outbox.is_empty(),
4826− "no message is queued for a blocked peer"
4829− m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4830− "the user is told why"
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?");
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.
4841− fn send_text_refuses_an_oversized_body() {
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);
4848− let _ = app.update(Event::SendText, &mut m);
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")));
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.
4860− fn send_text_clears_a_stale_error_on_success() {
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());
4868− m.compose = "hello".into();
4869− let _ = app.update(Event::SendText, &mut m);
4871− assert!(m.error.is_none(), "a successful send clears the stale error");
4872− assert!(!m.outbox.is_empty(), "the message was queued");
4875− // -----------------------------------------------------------------------
4876− // SaveProfile field length checks
4877− // -----------------------------------------------------------------------
4880− fn save_profile_rejects_an_oversized_display_name() {
4882− let mut m = with_identity();
4884− let _ = app.update(Event::SaveProfile {
4885− display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4886− bio: String::new(),
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")));
4896− fn save_profile_rejects_an_oversized_bio() {
4898− let mut m = with_identity();
4900− let _ = app.update(Event::SaveProfile {
4901− display_name: "Alice".into(),
4902− bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
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")));
4912− fn save_profile_rejects_an_oversized_photo() {
4914− let mut m = with_identity();
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)),
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")));
4927− // -----------------------------------------------------------------------
4928− // AddContact nickname length check
4929− // -----------------------------------------------------------------------
4932− fn add_contact_rejects_an_oversized_nickname() {
4934− let mut m = with_identity();
4935− let peer = peer_hex(9);
4937− let _ = app.update(Event::AddContact {
4938− input: peer.clone(),
4939− nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4942− assert!(!m.contacts.contains_key(&peer), "no contact created");
4943− assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4946− // -----------------------------------------------------------------------
4947− // FlushWatchdog unsticks a wedged send loop
4948− // -----------------------------------------------------------------------
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.
4954− fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
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"));
4961− let _ = app.update(Event::StartFlush, &mut m);
4962− assert!(m.flushing, "the send is in flight");
4963− assert!(m.in_flight.is_some());
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);
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");
4975− /// A watchdog that fires after the send already completed is a no-op.
4977− fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4979− let mut m = with_identity();
4980− m.token = Some("t".into());
4981− m.outbox.push_back(queued(&peer_hex(9), "hi"));
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());
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());
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.
5000− fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
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"));
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");
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");
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");