@@ -5,9 +5,13 @@
55//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
66//! QR scanning, photo picking, and clipboard.
77
8−use std::collections::{BTreeMap, VecDeque};
8+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
9+use std::sync::Arc;
910use std::time::{Duration, SystemTime, UNIX_EPOCH};
1011
12+use base64::prelude::*;
13+use base64::Engine;
14+
1115use crux_core::{
1216 macros::effect,
1317 render::{render, RenderOperation},
@@ -18,11 +22,13 @@ use crux_kv::KeyValueOperation;
1822use crux_time::TimeRequest;
1923use facet::Facet;
2024use serde::{Deserialize, Serialize};
25+use url::Url;
26+use zeroize::Zeroizing;
2127
22−use crate::crypto::Identity;
28+use crate::crypto::{Identity, MAX_BLOB_LEN};
2329use crate::model::{
2430 hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
25− Settings, StoredMessage, ViewModel, MAX_MESSAGES_PER_PEER,
31+ Settings, StoredMessage, ViewModel, DEFAULT_SERVER_URL, MAX_MESSAGES_PER_PEER,
2632};
2733use crate::phonemic;
2834use crate::protocol::{self, Envelope, Payload};
@@ -38,16 +44,121 @@ type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
3844/// peer park itself at the top of the conversation list.
3945const MAX_FUTURE_SKEW_MS: i64 = 60_000;
4046
47+/// Cap on stored contacts.
48+///
49+/// Any stranger can create one just by sending us a message, so without a cap a
50+/// spammer holding a list of pubkeys can grow `contacts` (and the `contacts` kv
51+/// blob, rewritten in full on every change) without bound. Past the cap we drop
52+/// mail from unknown keys; contacts the user added by hand are never at risk,
53+/// because `AddContact` doesn't go through this path.
54+const MAX_CONTACTS: usize = 500;
55+
56+/// Cap on the total blob bytes we will decrypt out of a single poll page.
57+///
58+/// A hostile relay can answer `/poll` with as many events as it likes. Each one
59+/// costs a hex decode, a scalar mult and an AEAD pass, all before we can tell it
60+/// is junk. `MAX_BLOB_LEN` bounds one blob; this bounds the page. Anything past
61+/// the budget is dropped exactly like an undecryptable blob would be.
62+const MAX_POLL_TOTAL_BYTES: usize = 64 * 1024 * 1024;
63+
64+/// Cap on the number of events we will attempt to decrypt out of a single poll
65+/// page, independent of the byte budget. Minimum-size blobs (173 bytes decoded)
66+/// would otherwise permit ~388K events within the 64 MiB byte budget, each
67+/// costing a full scalar mult + SHA-512 + AEAD pass — enough to freeze the app
68+/// for tens of seconds per poll. An honest relay caps at 50 events
69+/// (`maxPollEvents`); this is a hostile-relay bound. Set above `MAX_ACK_IDS`
70+/// (1024) so a legitimate large page from one sender still fully processes.
71+const MAX_POLL_EVENTS: usize = 2000;
72+
73+/// Upper bound on a poll response body we will buffer and parse, in bytes.
74+///
75+/// Before the per-blob byte/count budgets in `ingest_poll` ever run, the client
76+/// reads the entire response body into a `Vec<u8>` and deserializes it. A
77+/// multi-gigabyte response from a hostile relay would OOM the app before
78+/// `ingest_poll`'s defenses are consulted. This cap (generously above what 50
79+/// max-size blobs + JSON envelope could cost) bounds the allocation.
80+const MAX_POLL_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
81+
82+/// How long a poll may stay in flight before we assume the shell lost it.
83+///
84+/// `polling` gates the whole long-poll loop, and it is only cleared by a
85+/// `PollResult`. If the shell ever drops an HTTP effect (a cancelled task, a
86+/// backgrounded app, a bug), `polling` stays `true` and the client goes quiet
87+/// forever with no visible error. The watchdog re-arms the loop instead.
88+///
89+/// This **must** stay above the shell's own HTTP timeout (70 s, see
90+/// `Effects.swift`), or the watchdog stops being a watchdog: it would fire on
91+/// every poll that is merely slow rather than lost, abandoning a request the
92+/// shell is still going to resolve. The generation check in `PollResult` makes
93+/// that survivable, but each misfire still costs a redundant request to the
94+/// relay, so leave the margin.
95+const POLL_WATCHDOG_MS: u64 = 90_000;
96+
97+/// How long a send may stay in flight before we assume the shell lost it.
98+///
99+/// `flushing` gates the whole send loop, and it is only cleared by a
100+/// `SendResult`. If the shell ever drops the HTTP effect (a cancelled task, a
101+/// backgrounded app, a bug), `flushing` stays `true` and the outbox is
102+/// permanently stuck — new messages queue up but never send. The watchdog
103+/// releases the guard so the next `StartFlush` can retry.
104+///
105+/// Kept above the shell's 70 s HTTP timeout for the same reason as
106+/// `POLL_WATCHDOG_MS`: a misfire on a merely slow send is survivable (the late
107+/// `SendResult` finds `in_flight` already cleared and pops nothing), but each
108+/// one costs a redundant request.
109+const FLUSH_WATCHDOG_MS: u64 = 90_000;
110+
111+/// How long an auth round-trip may stay in flight before we assume the
112+/// shell lost it. Same rationale as `POLL_WATCHDOG_MS`: `authenticating`
113+/// gates the whole auth flow, and without a watchdog a dropped effect
114+/// leaves it stuck forever. Kept above the 70 s shell HTTP timeout.
115+const AUTH_WATCHDOG_MS: u64 = 90_000;
116+
117+/// Messages per `/messages` request.
118+///
119+/// The relay rejects a longer batch outright (`413 batch_too_large`,
120+/// PROTOCOL.md §7), so this is a hard ceiling, not a tuning knob.
121+const MAX_SEND_BATCH: usize = 100;
122+
123+/// ...and a byte ceiling on the same batch, counted in hex characters — i.e. in
124+/// the units the request body is actually built from.
125+///
126+/// The count limit alone is not enough: a hundred profile broadcasts each
127+/// carrying a 64 KiB photo would build a request far past the 16 MiB body cap a
128+/// relay has by default (`--http-max-body-bytes`, PROTOCOL.md §7), and the whole
129+/// batch would come back `413` — which we read as a transient failure and retry,
130+/// forever. Bounding the body here keeps the batch inside what any conforming
131+/// relay accepts. A single item over the budget still goes out alone: it is the
132+/// relay's job to reject it, and its rejection is per-message and permanent.
133+const MAX_SEND_BATCH_BYTES: usize = 4 * 1024 * 1024;
134+
135+/// Ceiling on a `Retry-After` we will honour from a relay.
136+///
137+/// The header is a number the relay chooses and we obey, so without a cap a
138+/// single `Retry-After: 999999999` — from a hostile relay, or a merely broken
139+/// one — parks the outbox for thirty years with nothing on screen to say why.
140+/// Five minutes is longer than any real rate-limit window and still recovers on
141+/// its own.
142+const MAX_RETRY_AFTER_MS: u64 = 5 * 60 * 1000;
143+
41144// kv keys
42145const K_SETTINGS: &str = "settings";
43146const K_PROFILE: &str = "profile";
44147const K_CONTACTS: &str = "contacts";
45148const K_CURSOR: &str = "cursor";
46149const K_OUTBOX: &str = "outbox";
150+const K_SEEN_IDS: &str = "seen_ids";
47151fn k_messages(peer: &str) -> String {
48152 format!("messages:{peer}")
49153}
50154
155+/// N7: Cap on the per-peer `seen_ids` dedup set. Without a cap the set grows
156+/// without bound — a peer who sends thousands of messages accumulates
157+/// thousands of IDs, and the `seen_ids` kv blob is rewritten in full on every
158+/// incoming message. Kept well above `MAX_MESSAGES_PER_PEER` (1000) so a
159+/// trim-and-replay within the conversation window still hits the set.
160+const MAX_SEEN_IDS_PER_PEER: usize = 2000;
161+
51162// ---------------------------------------------------------------------------
52163// Effect
53164// ---------------------------------------------------------------------------
@@ -85,6 +196,10 @@ pub enum Event {
85196 SendText,
86197 AddContact { input: String, nickname: String },
87198 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 },
88203 SaveProfile {
89204 display_name: String,
90205 bio: String,
@@ -111,27 +226,65 @@ pub enum Event {
111226 LoadedOutbox(#[facet(opaque)] KvData),
112227 #[serde(skip)]
113228 #[facet(skip)]
229+ LoadedSeenIds(#[facet(opaque)] KvData),
230+ #[serde(skip)]
231+ #[facet(skip)]
114232 LoadedMessages(String, #[facet(opaque)] KvData),
115233 #[serde(skip)]
116234 #[facet(skip)]
117235 Saved(#[facet(opaque)] KvData),
236+ /// The cursor write specifically: the next poll is chained off it, so that a
237+ /// message is never acked to the server before it is durable locally.
238+ #[serde(skip)]
239+ #[facet(skip)]
240+ SavedCursor(#[facet(opaque)] KvData),
118241
119242 // ---- internal: auth / poll / send ----
243+ // These drive the core's own loops and are never sent by the shell, so they
244+ // are kept off the FFI surface entirely.
245+ //
246+ // The auth and poll results carry the *generation* of the round-trip that
247+ // produced them. An in-flight effect is never cancelled — it can only be
248+ // superseded — so without a generation a result belonging to an attempt we
249+ // already abandoned lands in the handler for the attempt that replaced it.
250+ // See `Model::poll_gen`.
251+ #[serde(skip)]
252+ #[facet(skip)]
120253 Authenticate,
254+ #[serde(skip)]
255+ #[facet(skip)]
121256 Poll,
257+ /// Fires `POLL_WATCHDOG_MS` after generation `n`'s poll is issued; a no-op
258+ /// unless that exact poll is somehow still in flight.
259+ #[serde(skip)]
260+ #[facet(skip)]
261+ PollWatchdog(u64),
262+ #[serde(skip)]
263+ #[facet(skip)]
122264 StartFlush,
265+ /// Fires `FLUSH_WATCHDOG_MS` after generation `n`'s flush is issued; a no-op
266+ /// unless that exact send is somehow still in flight — same pattern as
267+ /// `PollWatchdog`.
268+ #[serde(skip)]
269+ #[facet(skip)]
270+ FlushWatchdog(u64),
271+ /// Fires `AUTH_WATCHDOG_MS` after generation `n`'s auth round-trip is
272+ /// issued; a no-op unless that exact auth is somehow still in flight.
123273 #[serde(skip)]
124274 #[facet(skip)]
125− ChallengeResult(#[facet(opaque)] HttpResult),
275+ AuthWatchdog(u64),
126276 #[serde(skip)]
127277 #[facet(skip)]
128− VerifyResult(#[facet(opaque)] HttpResult),
278+ ChallengeResult(u64, #[facet(opaque)] HttpResult),
129279 #[serde(skip)]
130280 #[facet(skip)]
131− PollResult(#[facet(opaque)] HttpResult),
281+ VerifyResult(u64, #[facet(opaque)] HttpResult),
132282 #[serde(skip)]
133283 #[facet(skip)]
134− SendResult(#[facet(opaque)] HttpResult),
284+ PollResult(u64, #[facet(opaque)] HttpResult),
285+ #[serde(skip)]
286+ #[facet(skip)]
287+ SendResult(u64, #[facet(opaque)] HttpResult),
135288}
136289
137290// ---------------------------------------------------------------------------
@@ -157,22 +310,137 @@ pub enum Page {
157310 EditProfile,
158311}
159312
313+/// The six kv keys loaded at startup (`settings`, `profile`, `contacts`,
314+/// `cursor`, `outbox`, `seen_ids`). `Connect` waits for all of them.
315+const STARTUP_LOADS: u8 = 6;
316+
317+/// The batch a live `/messages` request is carrying: the first `count` items of
318+/// the outbox, all bound for `recipient`.
319+///
320+/// `SendResult` needs all three. The count is what it pops on success and what it
321+/// charges a retry against on a transient failure; the recipient is what tells
322+/// `SetBlocked` and `DeleteContact` whether the items they are about to remove
323+/// are the very ones this send is going to pop; and `bytes` — the size of the
324+/// body we actually built — is what a `413` halves to find a batch this relay
325+/// will accept (see `Model::send_batch_budget`).
326+struct InFlight {
327+ recipient: String,
328+ count: usize,
329+ bytes: usize,
330+}
331+
160332pub struct Model {
161− secret_key: Option<Vec<u8>>,
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>>>,
162336 my_pubkey: String,
163337 settings: Settings,
164338 profile: OwnProfile,
165339 contacts: BTreeMap<String, Contact>,
166340 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>>,
167347 outbox: VecDeque<OutboxItem>,
168348 cursor: i64,
169349 token: Option<String>,
170350 conn: ConnStatus,
171351 poll_retries: u32,
352+ /// The send loop's own backoff counter, spent when a `429` arrives with no
353+ /// `Retry-After` to obey. Kept apart from `poll_retries` because the poll loop
354+ /// resets that one on every page it takes: a client polling happily while the
355+ /// relay rate-limits its sends would restart the send backoff at 3 s forever,
356+ /// which is not a backoff.
357+ send_retries: u32,
172358 /// A poll request is in flight. Guards against stacking concurrent long-poll
173359 /// loops (every extra loop re-polls itself forever and never terminates).
174360 polling: bool,
361+ /// Which poll the in-flight `PollResult` is allowed to belong to.
362+ ///
363+ /// Bumped every time a poll is issued, and every time one is *abandoned* (the
364+ /// watchdog gives up on it; a relay switch invalidates it). A `PollResult`
365+ /// whose generation no longer matches is dropped on the floor.
366+ ///
367+ /// A wall-clock "has it been long enough?" test cannot do this job. An HTTP
368+ /// effect stays pending across an iOS suspension while `now_ms()` runs on, so
369+ /// on resume *every* in-flight poll looks stuck — the watchdog abandons a
370+ /// request the shell is still about to resolve, and that resolution then
371+ /// clears the guard belonging to its replacement. Two poll loops, each
372+ /// re-polling itself forever. The generation is what makes abandoning safe.
373+ poll_gen: u64,
374+ /// An auth round-trip is in flight. `Connect` is fired from several places
375+ /// (startup, a 401, a reconnect timer, a server change); without this guard
376+ /// they stack, and each one that lands installs a token and starts its own
377+ /// poll loop.
378+ authenticating: bool,
379+ /// The same idea for the auth round-trip, which spans two legs (challenge →
380+ /// verify) and is restarted by `Connect` — which the shell fires on *every*
381+ /// return to the foreground (`SkrepkaApp.swift`). The challenge still in
382+ /// flight from before the app was backgrounded must not install a token or
383+ /// overwrite `conn` on top of the attempt that replaced it.
384+ auth_gen: u64,
385+ /// A `/messages` request is in flight. One at a time: `SendResult` pops the
386+ /// items it sent off the head of the outbox, so two overlapping sends would
387+ /// pop each other's messages.
175388 flushing: bool,
389+ /// Which flush the in-flight `SendResult` / `FlushWatchdog` is allowed to
390+ /// belong to. Same pattern as `poll_gen`: bumped every time a flush is
391+ /// issued and every time one is abandoned, so a `SendResult` or watchdog
392+ /// from a superseded send cannot clear the guard or pop items belonging to
393+ /// the replacement.
394+ flush_gen: u64,
395+ /// What the in-flight send will pop off the head of the outbox when it
396+ /// succeeds — see `InFlight`. `None` while nothing is in flight, and also
397+ /// while a send is in flight whose items have since been removed from under
398+ /// it (`SetBlocked`, `DeleteContact`): there is then nothing left to pop, and
399+ /// popping by count anyway would eat whichever messages slid into their place.
400+ in_flight: Option<InFlight>,
401+ /// What this relay will actually accept as a request body, in hex characters,
402+ /// once it has told us. `None` until then — meaning "assume
403+ /// `MAX_SEND_BATCH_BYTES`", which is a guess at a cap the protocol never
404+ /// publishes.
405+ ///
406+ /// A relay configured below our guess answers `413`, and a `413` read as a
407+ /// transient failure is a batch that is rebuilt identically, rejected
408+ /// identically, and retried until every item in it has burned its retry budget
409+ /// and been dropped — the outbox drains into the void without a single message
410+ /// leaving. So a `413` *measures* the cap instead: halve what we just tried and
411+ /// rebuild smaller. It is deliberately not reset on success — the batch that
412+ /// worked is evidence about the relay, and the relay's cap does not grow — only
413+ /// on a server change, where the evidence no longer applies.
414+ send_batch_budget: Option<usize>,
415+ /// Startup kv loads still outstanding. `Connect` only fires at 0, so a poll
416+ /// can't land and be persisted against a half-loaded model — which would
417+ /// then be clobbered by the loads still in flight.
418+ loads_pending: u8,
419+ /// A startup kv *read* failed — as opposed to finding the key absent.
420+ ///
421+ /// The distinction is the whole point of `parse_kv`'s `Result<Option<T>, ()>`:
422+ /// an absent key is a fresh install and defaulting is right, but a failed read
423+ /// leaves a hole in the model over data that is still on disk, and writing that
424+ /// hole back destroys it. The outbox is the sharpest case — `ingest_poll`
425+ /// persists it on *every* poll page, so a single failed read followed by one
426+ /// empty poll writes `[]` over a queue of real, unsent messages.
427+ ///
428+ /// So this latches the model as untrustworthy, and every write path checks it:
429+ /// no persist, no ingest, no flush. Nothing is lost, because nothing is
430+ /// written — the data stays on disk for the next launch, which re-runs the
431+ /// loads (`IdentityLoaded` fires on every boot) and clears the flag if they
432+ /// succeed. It is deliberately one flag and not a per-key set: a read that
433+ /// fails because the device is locked fails for every key, and a model that is
434+ /// wrong about its outbox is not one to be trusted about its contacts either.
435+ kv_load_failed: bool,
436+ /// `now_ms()` before which `flush_next` must not send.
437+ ///
438+ /// A `429` schedules a `StartFlush` for the far side of the relay's
439+ /// `Retry-After`, but that timer is not the only thing that fires one — a poll
440+ /// page and a fresh `SendText` both do. Without a pause the wait is decorative:
441+ /// the next poll page walks straight through it and hammers the relay that just
442+ /// asked us to stop.
443+ flush_paused_until: i64,
176444 page: Page,
177445 active_peer: Option<String>,
178446 compose: String,
@@ -188,13 +456,24 @@ impl Default for Model {
188456 profile: OwnProfile::default(),
189457 contacts: BTreeMap::new(),
190458 messages: BTreeMap::new(),
459+ seen_ids: BTreeMap::new(),
191460 outbox: VecDeque::new(),
192461 cursor: 0,
193462 token: None,
194463 conn: ConnStatus::Offline,
195464 poll_retries: 0,
465+ send_retries: 0,
196466 polling: false,
467+ poll_gen: 0,
468+ authenticating: false,
469+ auth_gen: 0,
197470 flushing: false,
471+ flush_gen: 0,
472+ in_flight: None,
473+ send_batch_budget: None,
474+ loads_pending: 0,
475+ kv_load_failed: false,
476+ flush_paused_until: 0,
198477 page: Page::default(),
199478 active_peer: None,
200479 compose: String::new(),
@@ -209,50 +488,144 @@ impl Model {
209488 .as_ref()
210489 .and_then(|sk| Identity::from_secret_bytes(sk).ok())
211490 }
491+
492+ /// Tick off one startup load. Returns `true` exactly once — when the last of
493+ /// the five lands — so `Connect` is emitted a single time.
494+ fn startup_load_done(&mut self) -> bool {
495+ if self.loads_pending == 0 {
496+ return false;
497+ }
498+ self.loads_pending -= 1;
499+ self.loads_pending == 0
500+ }
501+
502+ /// Open a new poll generation and return it. Any `PollResult` still in flight
503+ /// under the old generation is now dead to us.
504+ fn next_poll_gen(&mut self) -> u64 {
505+ self.poll_gen = self.poll_gen.wrapping_add(1);
506+ self.poll_gen
507+ }
508+
509+ /// Abandon the in-flight poll, if any: release the loop guard and retire the
510+ /// generation so a late `PollResult` cannot clear the *replacement* poll's
511+ /// guard or fold a stale page (and a stale cursor) into the model.
512+ fn abandon_poll(&mut self) {
513+ self.polling = false;
514+ self.poll_gen = self.poll_gen.wrapping_add(1);
515+ }
516+
517+ /// Open a new auth generation and return it.
518+ fn next_auth_gen(&mut self) -> u64 {
519+ self.auth_gen = self.auth_gen.wrapping_add(1);
520+ self.auth_gen
521+ }
522+
523+ /// Open a new flush generation and return it. Same pattern as
524+ /// `next_poll_gen`: a `SendResult` or `FlushWatchdog` whose generation no
525+ /// longer matches is dropped, so a late result from a superseded send
526+ /// cannot clear the guard or pop items belonging to the replacement.
527+ fn next_flush_gen(&mut self) -> u64 {
528+ self.flush_gen = self.flush_gen.wrapping_add(1);
529+ self.flush_gen
530+ }
531+
532+ /// Abandon the in-flight flush, if any: release the guard and retire the
533+ /// generation so a late `SendResult` cannot clear the *replacement* flush's
534+ /// guard or pop its items.
535+ fn abandon_flush(&mut self) {
536+ self.flushing = false;
537+ self.in_flight = None;
538+ self.flush_gen = self.flush_gen.wrapping_add(1);
539+ }
540+
541+ /// Abandon the in-flight auth round-trip (either leg).
542+ fn abandon_auth(&mut self) {
543+ self.authenticating = false;
544+ self.auth_gen = self.auth_gen.wrapping_add(1);
545+ }
546+
547+ /// Every item bound for `peer` is about to be removed from the outbox. If the
548+ /// send in flight is the one carrying them, forget what it was going to pop:
549+ /// those items will not be there when it lands, and popping by count anyway
550+ /// would take whatever slid into their place — another peer's mail.
551+ ///
552+ /// The `flushing` guard stays up. Nothing can cancel the request; it is still
553+ /// out there, and a second send started underneath it would have its own items
554+ /// popped by the first one's result.
555+ fn detach_in_flight(&mut self, peer: &str) {
556+ if self.in_flight.as_ref().is_some_and(|f| f.recipient == peer) {
557+ self.in_flight = None;
558+ }
559+ }
560+
561+ /// How many items at the head of the outbox are already on the wire, and so
562+ /// must not be rewritten in place (their ciphertext is built and gone).
563+ fn in_flight_count(&self) -> usize {
564+ self.in_flight.as_ref().map_or(0, |f| f.count)
565+ }
212566}
213567
214568// ---------------------------------------------------------------------------
215569// Helpers (pure)
216570// ---------------------------------------------------------------------------
217571
572+/// Wall-clock milliseconds.
573+///
574+/// This reads the system clock from inside `update()`, which is supposed to be a
575+/// pure function of `(Event, Model)`. It isn't, and that has real costs: the
576+/// state machine can't be replayed deterministically, and tests can't pin "now".
577+/// The clean fix is to take the time as a `crux_time` effect and feed it back in
578+/// as an event — but every timestamped path (`SendText`, `SaveProfile`,
579+/// `ingest_poll`) would have to become a two-step round-trip through the shell.
580+/// That refactor is worth doing; it is not worth folding into a bug-fix pass, so
581+/// the impurity is documented rather than hidden.
582+// TODO: replace with crux_time effect for deterministic timestamps
218583fn now_ms() -> i64 {
219584 SystemTime::now()
220585 .duration_since(UNIX_EPOCH)
221586 .map(|d| d.as_millis() as i64)
222− .unwrap_or(0)
587+ .unwrap_or(1)
223588}
224589
225590/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
591+///
592+/// This feeds the auth signature, which is bound to the host (PROTOCOL.md §6),
593+/// so it must agree with what the relay itself computes. Splitting on `:` by hand
594+/// mangles an IPv6 literal (`http://[::1]:8080` → `[`), producing a signature the
595+/// server rejects; `Url` gets the bracket rules right.
226596fn server_host(url: &str) -> String {
227− let after_scheme = url.split("://").nth(1).unwrap_or(url);
228− let host_port = after_scheme.split('/').next().unwrap_or("");
229− let host = host_port.split('@').next_back().unwrap_or(host_port);
230− let host = host.split(':').next().unwrap_or(host);
231− host.trim_end_matches('.').to_lowercase()
597+ Url::parse(url)
598+ .ok()
599+ .and_then(|u| u.host_str().map(str::to_string))
600+ .map(|h| h.trim_end_matches('.').to_lowercase())
601+ .unwrap_or_default()
232602}
233603
234604/// Validate and canonicalize a user-entered relay URL.
235605///
236−/// The shell hands us raw text-field contents, and `Http::post` panics on a URL
237−/// with no scheme — so a schemeless entry must be rejected here, in the core,
238−/// before it can ever reach an effect. Returns the normalized URL (lowercased
239−/// scheme, no trailing slash) or `None` if it isn't an absolute http(s) 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.
611+///
612+/// A query or fragment is rejected too. Every request appends a path
613+/// (`{url}/poll`), so `http://x?y` would yield `http://x?y/poll` — the path
614+/// swallowed into the query string, pointing at nothing.
615+///
616+/// Returns the normalized URL (no trailing slash) or `None`.
240617fn normalize_server_url(input: &str) -> Option<String> {
241− let (scheme, rest) = input.trim().split_once("://")?;
242− if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
618+ let url = Url::parse(input.trim()).ok()?;
619+ if !matches!(url.scheme(), "http" | "https") {
243620 return None;
244621 }
245− let rest = rest.trim_end_matches('/');
246− if rest.chars().any(char::is_whitespace) {
622+ if url.host_str().is_none_or(str::is_empty) {
247623 return None;
248624 }
249− // Require a non-empty authority: reject "http://", "http:///x", "http://:80".
250− let authority = rest.split('/').next().unwrap_or("");
251− let host = authority.split('@').next_back().unwrap_or(authority);
252− if host.split(':').next().unwrap_or("").is_empty() {
625+ if url.query().is_some() || url.fragment().is_some() {
253626 return None;
254627 }
255− Some(format!("{}://{}", scheme.to_lowercase(), rest))
628+ Some(url.as_str().trim_end_matches('/').to_string())
256629}
257630
258631fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
@@ -299,6 +672,16 @@ struct PollEvent {
299672 #[serde(rename = "encryptedBlob")]
300673 encrypted_blob: String,
301674}
675+
676+/// Q17: Deferred delivery-ack accumulator. Collects ack_ids and the max `ts`
677+/// across all `delivery.ack` payloads from one sender in a single poll page,
678+/// so the staleness check and watermark update run once per sender after the
679+/// event loop — immune to relay page ordering.
680+#[derive(Default)]
681+struct PendingAck {
682+ ts: i64,
683+ ids: HashSet<String>,
684+}
302685#[derive(Serialize)]
303686struct SendBatch {
304687 messages: Vec<Envelope>,
@@ -326,65 +709,158 @@ impl App for Skrepka {
326709 model.error = Some("invalid identity key".into());
327710 return render();
328711 };
329− model.secret_key = Some(sk);
712+ model.secret_key = Some(Zeroizing::new(sk));
330713 model.my_pubkey = id.public_key_hex();
331714 model.page = Page::Conversations;
332− // Fan out the startup loads.
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;
333722 Command::all([
334723 KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
335724 KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
336725 KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
337726 KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
338727 KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
728+ KeyValue::get(K_SEEN_IDS).then_send(Event::LoadedSeenIds),
339729 ])
340730 .and(render())
341731 }
342732
343733 Event::LoadedSettings(res) => {
344− if let Some(s) = parse_kv::<Settings>(res) {
345− model.settings = s;
734+ match parse_kv::<Settings>(res) {
735+ Ok(Some(s)) => {
736+ // Re-validate on the way in. The URL was normalized when the
737+ // user typed it, but the blob on disk is not trustworthy: an
738+ // older build wrote URLs this parser rejects, and a corrupted
739+ // or hand-edited file would reach `Http::post`, which unwraps
740+ // the parse. That is a panic on every launch — a boot loop
741+ // with no way out from inside the app.
742+ model.settings.server_url = normalize_server_url(&s.server_url)
743+ .unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
744+ }
745+ Ok(None) => {}
746+ Err(()) => {
747+ model.kv_load_failed = true;
748+ model.error = Some("could not read settings from storage".into());
749+ }
346750 }
347− // Server URL known — connect and resume any queued sends.
348− Command::event(Event::Connect)
751+ self.startup_done(model).and(render())
349752 }
350753 Event::LoadedProfile(res) => {
351− if let Some(p) = parse_kv::<OwnProfile>(res) {
352− model.profile = p;
754+ match parse_kv::<OwnProfile>(res) {
755+ Ok(Some(p)) => model.profile = p,
756+ Ok(None) => {}
757+ Err(()) => {
758+ model.kv_load_failed = true;
759+ model.error = Some("could not read profile from storage".into());
760+ }
353761 }
354− render()
762+ self.startup_done(model).and(render())
355763 }
356764 Event::LoadedContacts(res) => {
357− if let Some(list) = parse_kv::<Vec<Contact>>(res) {
358− model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
359− // Lazily load each conversation's messages.
360− let loads: Vec<_> = model
361− .contacts
362− .keys()
363− .map(|peer| {
765+ let mut cmds = vec![self.startup_done(model)];
766+ match parse_kv::<Vec<Contact>>(res) {
767+ Ok(Some(list)) => {
768+ model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
769+ // Lazily load each conversation's messages.
770+ cmds.extend(model.contacts.keys().map(|peer| {
364771 let peer = peer.clone();
365772 KeyValue::get(k_messages(&peer))
366773 .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
367− })
368− .collect();
369− return Command::all(loads).and(render());
774+ }));
775+ }
776+ Ok(None) => {}
777+ Err(()) => {
778+ model.kv_load_failed = true;
779+ model.error = Some("could not read contacts from storage".into());
780+ }
370781 }
371− render()
782+ Command::all(cmds).and(render())
372783 }
373784 Event::LoadedCursor(res) => {
374− if let Some(c) = parse_kv::<i64>(res) {
375− model.cursor = c;
785+ match parse_kv::<i64>(res) {
786+ Ok(Some(c)) => model.cursor = c,
787+ Ok(None) => {}
788+ Err(()) => {
789+ model.kv_load_failed = true;
790+ model.error = Some("could not read cursor from storage".into());
791+ }
376792 }
377− render()
793+ self.startup_done(model).and(render())
378794 }
379795 Event::LoadedOutbox(res) => {
380− if let Some(list) = parse_kv::<Vec<OutboxItem>>(res) {
381− model.outbox = list.into();
796+ match parse_kv::<Vec<OutboxItem>>(res) {
797+ Ok(Some(list)) => model.outbox = list.into(),
798+ Ok(None) => {}
799+ Err(()) => {
800+ model.kv_load_failed = true;
801+ model.error = Some("could not read outbox from storage".into());
802+ }
382803 }
383− render()
804+ self.startup_done(model).and(render())
805+ }
806+ Event::LoadedSeenIds(res) => {
807+ match parse_kv::<BTreeMap<String, HashSet<String>>>(res) {
808+ Ok(Some(map)) => model.seen_ids = map,
809+ Ok(None) => {}
810+ Err(()) => {
811+ // Non-fatal: dedup falls back to the conversation-derived
812+ // set, which is weaker but functional.
813+ model.seen_ids = BTreeMap::new();
814+ }
815+ }
816+ self.startup_done(model).and(render())
384817 }
385818 Event::LoadedMessages(peer, res) => {
386− if let Some(list) = parse_kv::<Vec<StoredMessage>>(res) {
387− model.messages.insert(peer, list);
819+ match parse_kv::<Vec<StoredMessage>>(res) {
820+ Ok(Some(list)) => {
821+ // D5: Add on-disk message IDs to seen_ids so future
822+ // replays don't pass dedup. Without this, a seen_ids
823+ // kv load failure leaves the set empty, and on-disk
824+ // messages aren't protected against replay.
825+ if let Some(id_set) = model.seen_ids.get_mut(&peer) {
826+ id_set.extend(list.iter().map(|m| m.id.clone()));
827+ }
828+ // Merge: a poll that beat this lazy load may have already
829+ // inserted messages for this peer. Overwriting with the
830+ // on-disk list (which the GET read before the poll wrote)
831+ // would silently destroy the freshly-ingested messages.
832+ // Union by id, keeping both the on-disk history and any
833+ // in-memory additions.
834+ match model.messages.get_mut(&peer) {
835+ Some(existing) => {
836+ let existing_ids: HashSet<&String> =
837+ existing.iter().map(|m| &m.id).collect();
838+ let new_msgs: Vec<StoredMessage> = list
839+ .into_iter()
840+ .filter(|m| !existing_ids.contains(&m.id))
841+ .collect();
842+ existing.extend(new_msgs);
843+ // Re-sort by ts to maintain the invariant.
844+ existing.sort_by_key(|m| m.ts);
845+ trim_history(existing);
846+ }
847+ None => {
848+ model.messages.insert(peer, list);
849+ }
850+ }
851+ }
852+ Ok(None) => {}
853+ Err(()) => {
854+ // Leaving the conversation absent is not enough on its own.
855+ // An absent conversation is an *empty* one to everything
856+ // downstream, so the next message from this peer would be
857+ // appended to nothing and `persist_messages` would write
858+ // that one message over the whole history still on disk.
859+ // Latch the model as untrustworthy instead — same reasoning
860+ // as the outbox, same fix.
861+ model.kv_load_failed = true;
862+ model.error = Some("could not read messages from storage".into());
863+ }
388864 }
389865 render()
390866 }
@@ -397,15 +873,32 @@ impl App for Skrepka {
397873 }
398874 Command::done()
399875 }
876+ Event::SavedCursor(res) => {
877+ // The cursor write is what tells the relay it may delete the mail
878+ // we just took (it acks up to `cursor` on the next poll). Only
879+ // re-poll once it has actually landed: if we polled in parallel
880+ // and the write failed, the messages would be gone from the
881+ // server and absent from disk.
882+ if let Err(e) = res {
883+ model.error = Some(format!("could not save data: {e}"));
884+ return self.backoff_poll(model);
885+ }
886+ Command::event(Event::Poll)
887+ }
400888
401889 // ---------------- 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.
402893 Event::ShowConversations => {
403894 model.page = Page::Conversations;
404895 model.active_peer = None;
896+ model.error = None;
405897 render()
406898 }
407899 Event::ShowSettings => {
408900 model.page = Page::Settings;
901+ model.error = None;
409902 render()
410903 }
411904 Event::ShowAddContact => {
@@ -415,16 +908,19 @@ impl App for Skrepka {
415908 }
416909 Event::ShowEditProfile => {
417910 model.page = Page::EditProfile;
911+ model.error = None;
418912 render()
419913 }
420914 Event::OpenChat(peer) => {
421915 model.active_peer = Some(peer);
422916 model.page = Page::Chat;
917+ model.error = None;
423918 render()
424919 }
425920 Event::Back => {
426921 model.page = Page::Conversations;
427922 model.active_peer = None;
923+ model.error = None;
428924 render()
429925 }
430926
@@ -435,26 +931,102 @@ impl App for Skrepka {
435931 }
436932 Event::SetServerUrl(url) => {
437933 let Some(url) = normalize_server_url(&url) else {
438− model.error = Some("server URL must start with http:// or https://".into());
934+ model.error = Some("server URL must be an http(s) address".into());
439935 return render();
440936 };
441937 model.error = None;
442938 model.settings.server_url = url;
443939 model.token = None;
444940 model.conn = ConnStatus::Offline;
445− KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
446− .then_send(Event::Saved)
447− .and(Command::event(Event::Connect))
448− .and(render())
941+ // Whatever we learned about the old relay's body cap was about *it*.
942+ // A new relay is entitled to a bigger one, and carrying a shrunken
943+ // budget over would cap our batches against a limit that no longer
944+ // exists — with no `413` left to correct it, since the new relay has
945+ // no reason to send one.
946+ model.send_batch_budget = None;
947+ // The cursor is *per relay*: it is that server's own sequence
948+ // number, and the next poll acks everything up to it. Carrying
949+ // relay A's cursor (roughly `now_ms()`) over to relay B tells B
950+ // to delete every message queued for us before it hands any of
951+ // them over. Start B from the beginning.
952+ model.cursor = 0;
953+ // ...and retire the poll that is still in flight *to relay A*, or
954+ // resetting the cursor achieves nothing: A's answer is already on
955+ // its way, it carries A's cursor, and `ingest_poll` would write it
956+ // straight back over the zero we just set. The next poll — to B —
957+ // would then ack a sequence number B never issued, and B would
958+ // drop every message waiting for us.
959+ model.abandon_poll();
960+ // ...and retire the send that is still in flight *to relay A*,
961+ // for the same reason: its `SendResult` carries A's acceptance,
962+ // and popping items here would eat whatever the new relay's
963+ // send is about to carry. The generation check in `SendResult`
964+ // is what makes the late result a no-op.
965+ model.abandon_flush();
966+ // The old relay's rate limit was the old relay's opinion. A new one is
967+ // entitled to take our sends immediately, and carrying the pause over
968+ // would park the outbox against a limit that no longer applies.
969+ model.flush_paused_until = 0;
970+ model.send_retries = 0;
971+ // D2: Reset poll retries too — the new relay's poll failures
972+ // should start fresh, not inherit the old relay's backoff.
973+ model.poll_retries = 0;
974+ let writes = if self.refuse_write(model) {
975+ Command::done()
976+ } else {
977+ Command::all([
978+ KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
979+ .then_send(Event::Saved),
980+ KeyValue::set(K_CURSOR, json_bytes(&0i64)).then_send(Event::Saved),
981+ ])
982+ };
983+ writes.and(Command::event(Event::Connect)).and(render())
449984 }
450985 Event::Connect => {
986+ // Connect restarts the auth flow, so whatever challenge/verify was
987+ // in flight is now stale and its guard must come off. Retiring the
988+ // generation as we do it is what makes that safe: the shell fires
989+ // Connect on every return to the foreground, so the round-trip we
990+ // are dropping here is usually one that is still very much alive
991+ // and about to resolve. Clearing the guard alone would let it land
992+ // and install its token on top of the attempt replacing it.
993+ model.abandon_auth();
994+ // The shell fires `Connect` on every return to the foreground. A model
995+ // that failed a startup read must not take that invitation: connecting
996+ // leads to polling, and polling into a hole acks mail to the relay that
997+ // we cannot store. Staying offline is what keeps the disk intact until
998+ // a relaunch re-runs the loads.
999+ if model.kv_load_failed {
1000+ return render();
1001+ }
4511002 if model.secret_key.is_some() && model.conn != ConnStatus::Online {
4521003 Command::event(Event::Authenticate)
1004+ } else if model.secret_key.is_some()
1005+ && model.conn == ConnStatus::Online
1006+ && !model.polling
1007+ {
1008+ // The poll loop can die without `conn` going offline: after
1009+ // `PollResult` clears `polling`, the re-poll is chained off
1010+ // `SavedCursor`. If the shell drops that effect (backgrounded
1011+ // app, bug), `polling` stays `false` with no pending re-poll
1012+ // and the watchdog sees `!polling` and does nothing. The
1013+ // status shows "online" but no polling ever happens. Restart
1014+ // the loop here — `Connect` is fired on every foreground return.
1015+ Command::event(Event::Poll)
4531016 } else {
4541017 render()
4551018 }
4561019 }
4571020 Event::AddContact { input, nickname } => {
1021+ // Clear a stale error from a previous failed add.
1022+ model.error = None;
1023+ // A nickname is stored in the contacts kv blob, which is
1024+ // rewritten in full on every change. An unbounded nickname
1025+ // bloats the blob and every subsequent write.
1026+ if nickname.len() > protocol::MAX_DISPLAY_NAME_LEN {
1027+ model.error = Some("nickname too long".into());
1028+ return render();
1029+ }
4581030 match phonemic::try_parse_pubkey(&input) {
4591031 Some(hex) if hex != model.my_pubkey => {
4601032 model
@@ -484,13 +1056,80 @@ impl App for Skrepka {
4841056 if let Some(c) = model.contacts.get_mut(&peer) {
4851057 c.blocked = blocked;
4861058 }
1059+ // Clear a stale error (e.g. "cannot send to a blocked contact")
1060+ // when the user unblocks — the block that caused it is now lifted.
1061+ model.error = None;
1062+ if blocked {
1063+ // Gating *ingest* on the block only stops the acks we have not
1064+ // queued yet. Anything already sitting in the outbox for this
1065+ // peer — an ack for the very message that made the user block
1066+ // them, a profile from the last broadcast — still flushes, and
1067+ // an ack is precisely the "we are online and reading you"
1068+ // signal the block is supposed to cut off. Drop it all: once
1069+ // blocked, nothing more goes to this peer.
1070+ model.outbox.retain(|item| item.recipient != peer);
1071+ model.detach_in_flight(&peer);
1072+ return Command::all([self.persist_contacts(model), self.persist_outbox(model)])
1073+ .and(render());
1074+ }
4871075 self.persist_contacts(model).and(render())
4881076 }
1077+ Event::DeleteContact { peer } => {
1078+ model.contacts.remove(&peer);
1079+ model.messages.remove(&peer);
1080+ model.seen_ids.remove(&peer);
1081+ model.outbox.retain(|item| item.recipient != peer);
1082+ // The send in flight may be carrying the very items we just
1083+ // dropped; if so its result must pop nothing.
1084+ model.detach_in_flight(&peer);
1085+ if model.active_peer.as_deref() == Some(peer.as_str()) {
1086+ model.active_peer = None;
1087+ model.page = Page::Conversations;
1088+ }
1089+ model.error = None;
1090+ Command::all([
1091+ self.persist_contacts(model),
1092+ // The conversation is gone from the model, but `persist_messages`
1093+ // would leave a `messages:<peer>` blob behind holding every
1094+ // message the user just asked us to forget. Delete the key.
1095+ KeyValue::delete(k_messages(&peer)).then_send(Event::Saved),
1096+ self.persist_outbox(model),
1097+ // Q18: persist the seen_ids map without the removed peer.
1098+ self.persist_seen_ids(model),
1099+ ])
1100+ .and(render())
1101+ }
4891102 Event::SaveProfile {
4901103 display_name,
4911104 bio,
4921105 photo,
4931106 } => {
1107+ // Clear a stale error from a previous failed save.
1108+ model.error = None;
1109+ // The recipient's `parse_payload` enforces these caps and
1110+ // silently drops oversized profiles. Without these checks the
1111+ // sender's local profile would be saved, broadcast to every
1112+ // contact, and silently dropped by every recipient — a silent
1113+ // divergence between what the sender sees and what contacts see.
1114+ if display_name.chars().count() > protocol::MAX_DISPLAY_NAME_LEN {
1115+ model.error = Some("display name too long".into());
1116+ return render();
1117+ }
1118+ if bio.chars().count() > protocol::MAX_BIO_LEN {
1119+ model.error = Some("bio too long".into());
1120+ return render();
1121+ }
1122+ if photo.as_ref().is_some_and(|p| p.len() > protocol::MAX_PHOTO_LEN) {
1123+ model.error = Some("photo too large".into());
1124+ return render();
1125+ }
1126+ // S6: Validate the photo is valid base64 before broadcasting —
1127+ // the recipient's parse_payload rejects invalid base64, so an
1128+ // invalid photo would be silently dropped by every recipient.
1129+ if photo.as_ref().is_some_and(|p| BASE64_STANDARD.decode(p).is_err()) {
1130+ model.error = Some("photo is not valid base64".into());
1131+ return render();
1132+ }
4941133 model.profile = OwnProfile {
4951134 display_name,
4961135 bio,
@@ -499,25 +1138,53 @@ impl App for Skrepka {
4991138 model.page = Page::Conversations;
5001139 let ts = now_ms();
5011140 let payload = Payload::Profile {
502− display_name: model.profile.display_name.clone(),
503− bio: model.profile.bio.clone(),
1141+ display_name: Some(model.profile.display_name.clone()),
1142+ bio: Some(model.profile.bio.clone()),
5041143 photo: model.profile.photo.clone(),
5051144 };
506− // Broadcast the new profile to every contact.
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.
5071149 //
508− // Each item carries its own copy of the serialized payload, so a
509− // profile with a photo is duplicated (base64 and all) once per
510− // contact, in memory and in the `outbox` kv blob. Acceptable at
511− // today's contact counts; the fix is to intern the payload and
512− // have `OutboxItem` reference it by id.
513− for peer in model.contacts.keys().cloned().collect::<Vec<_>>() {
514− model.outbox.push_back(OutboxItem {
515− recipient: peer,
516− envelope_json: protocol::serialize_payload(&payload, ts),
517− });
1150+ // One `Arc<String>` for the whole fan-out: the payload carries a
1151+ // base64 photo, and a copy per contact would be megabytes in memory
1152+ // for a list of any size. (The `outbox` kv blob still serializes the
1153+ // string once per item — serde has no idea the `Arc`s are shared —
1154+ // but the supersede-in-place below caps that at one pending profile
1155+ // per contact, which is the bound that actually matters.)
1156+ let recipients: Vec<String> = model
1157+ .contacts
1158+ .values()
1159+ .filter(|c| !c.blocked)
1160+ .map(|c| c.pubkey.clone())
1161+ .collect();
1162+ let payload_json = Arc::new(protocol::serialize_payload(&payload, ts));
1163+ // Items already on the wire cannot be rewritten: their ciphertext is
1164+ // built and gone, and `SendResult` will pop them on success — so a
1165+ // payload swapped into one now would be dropped having never been
1166+ // sent. Supersede only what is still behind them.
1167+ let live = model.in_flight_count();
1168+ for peer in recipients {
1169+ // A profile is state, not an event: only the newest one means
1170+ // anything. Overwrite the pending one for this peer instead of
1171+ // queueing a second — five quick edits to a profile with a photo
1172+ // would otherwise be five payloads per contact, each of which we
1173+ // then encrypt and send.
1174+ match model
1175+ .outbox
1176+ .iter_mut()
1177+ .skip(live)
1178+ .find(|i| i.is_profile() && i.recipient == peer)
1179+ {
1180+ Some(pending) => pending.envelope_json = Arc::clone(&payload_json),
1181+ None => model
1182+ .outbox
1183+ .push_back(OutboxItem::profile(peer, Arc::clone(&payload_json))),
1184+ }
5181185 }
5191186 Command::all([
520− KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved),
1187+ self.persist_profile(model),
5211188 self.persist_outbox(model),
5221189 ])
5231190 .and(Command::event(Event::StartFlush))
@@ -527,6 +1194,15 @@ impl App for Skrepka {
5271194
5281195 // ---------------- auth ----------------
5291196 Event::Authenticate => {
1197+ // Exactly one auth round-trip at a time. `Authenticate` is reached
1198+ // from startup, a 401 on poll, a 401 on send, a reconnect timer
1199+ // and a server change — all of which can fire at once after a
1200+ // network blip. Each one that got through would hit
1201+ // `/auth/challenge`, and each token that came back would install
1202+ // itself and kick off another poll loop.
1203+ if model.authenticating {
1204+ return Command::done();
1205+ }
5301206 let Some(id) = model.identity() else {
5311207 return render();
5321208 };
@@ -537,7 +1213,18 @@ impl App for Skrepka {
5371213 pubkey: &id.public_key_hex(),
5381214 };
5391215 match Http::post(url).body_json(&body) {
540− Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
1216+ Ok(req) => {
1217+ model.authenticating = true;
1218+ let gen = model.next_auth_gen();
1219+ req.build()
1220+ .then_send(move |r| Event::ChallengeResult(gen, r))
1221+ .and(
1222+ Time::notify_after(Duration::from_millis(AUTH_WATCHDOG_MS))
1223+ .0
1224+ .then_send(move |_| Event::AuthWatchdog(gen)),
1225+ )
1226+ .and(render())
1227+ }
5411228 Err(_) => {
5421229 model.conn = ConnStatus::Offline;
5431230 model.error = Some("could not build auth request".into());
@@ -545,42 +1232,67 @@ impl App for Skrepka {
5451232 }
5461233 }
5471234 }
548− Event::ChallengeResult(Ok(mut resp)) => {
1235+ Event::ChallengeResult(gen, res) => {
1236+ // A challenge from an attempt we already gave up on (a relay
1237+ // switch, a foreground `Connect`). Its `authenticating` is not
1238+ // ours to clear and its challenge is not ours to sign.
1239+ if gen != model.auth_gen {
1240+ return Command::done();
1241+ }
1242+ // Cleared here and re-set only if the verify leg actually goes
1243+ // out — every exit from this arm must leave the guard consistent.
1244+ model.authenticating = false;
1245+ let Ok(mut resp) = res else {
1246+ model.conn = ConnStatus::Offline;
1247+ return self.schedule_reconnect(model);
1248+ };
5491249 let bytes = resp.take_body().unwrap_or_default();
5501250 let Some(id) = model.identity() else {
5511251 return render();
5521252 };
553− match serde_json::from_slice::<ChallengeResp>(&bytes) {
554− Ok(c) => {
555− let host = server_host(&model.settings.server_url);
556− let signature = id.sign_challenge(&host, &c.challenge);
557− let url = format!("{}/auth/verify", model.settings.server_url);
558− let body = VerifyReq {
559− pubkey: &id.public_key_hex(),
560− challenge: &c.challenge,
561− signature: &signature,
562− revoke_others: false,
563− };
564− match Http::post(url).body_json(&body) {
565− Ok(req) => req.build().then_send(Event::VerifyResult),
566− Err(_) => {
567− model.conn = ConnStatus::Offline;
568− model.error = Some("could not build auth request".into());
569− self.schedule_reconnect(model)
570− }
571− }
1253+ let Ok(c) = serde_json::from_slice::<ChallengeResp>(&bytes) else {
1254+ model.conn = ConnStatus::Offline;
1255+ return self.schedule_reconnect(model);
1256+ };
1257+ let host = server_host(&model.settings.server_url);
1258+ let Ok(signature) = id.sign_challenge(&host, &c.challenge) else {
1259+ // An over-long challenge: a relay trying to get us to sign
1260+ // something of its choosing. Back off rather than comply.
1261+ model.conn = ConnStatus::Offline;
1262+ model.error = Some("relay sent a malformed auth challenge".into());
1263+ return self.schedule_reconnect(model);
1264+ };
1265+ let url = format!("{}/auth/verify", model.settings.server_url);
1266+ let body = VerifyReq {
1267+ pubkey: &id.public_key_hex(),
1268+ challenge: &c.challenge,
1269+ signature: &signature,
1270+ revoke_others: false,
1271+ };
1272+ match Http::post(url).body_json(&body) {
1273+ Ok(req) => {
1274+ model.authenticating = true;
1275+ // The verify leg belongs to the same attempt as the
1276+ // challenge that produced it, so it keeps the generation
1277+ // rather than opening a new one.
1278+ req.build().then_send(move |r| Event::VerifyResult(gen, r))
5721279 }
5731280 Err(_) => {
5741281 model.conn = ConnStatus::Offline;
1282+ model.error = Some("could not build auth request".into());
5751283 self.schedule_reconnect(model)
5761284 }
5771285 }
5781286 }
579− Event::ChallengeResult(Err(_)) => {
580− model.conn = ConnStatus::Offline;
581− self.schedule_reconnect(model)
582− }
583− Event::VerifyResult(Ok(mut resp)) => {
1287+ Event::VerifyResult(gen, res) => {
1288+ if gen != model.auth_gen {
1289+ return Command::done();
1290+ }
1291+ model.authenticating = false;
1292+ let Ok(mut resp) = res else {
1293+ model.conn = ConnStatus::Offline;
1294+ return self.schedule_reconnect(model);
1295+ };
5841296 let bytes = resp.take_body().unwrap_or_default();
5851297 match serde_json::from_slice::<VerifyResp>(&bytes) {
5861298 Ok(v) if !v.token.is_empty() => {
@@ -598,10 +1310,6 @@ impl App for Skrepka {
5981310 }
5991311 }
6001312 }
601− Event::VerifyResult(Err(_)) => {
602− model.conn = ConnStatus::Offline;
603− self.schedule_reconnect(model)
604− }
6051313
6061314 // ---------------- poll ----------------
6071315 Event::Poll => {
@@ -623,7 +1331,16 @@ impl App for Skrepka {
6231331 {
6241332 Ok(req) => {
6251333 model.polling = true;
626− req.build().then_send(Event::PollResult)
1334+ let gen = model.next_poll_gen();
1335+ // Arm the watchdog alongside the request: if this poll
1336+ // never resolves, nothing else will ever clear `polling`.
1337+ req.build()
1338+ .then_send(move |r| Event::PollResult(gen, r))
1339+ .and(
1340+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1341+ .0
1342+ .then_send(move |_| Event::PollWatchdog(gen)),
1343+ )
6271344 }
6281345 Err(_) => {
6291346 model.error = Some("could not build poll request".into());
@@ -631,8 +1348,36 @@ impl App for Skrepka {
6311348 }
6321349 }
6331350 }
634− Event::PollResult(Ok(mut resp)) => {
1351+ Event::PollWatchdog(gen) => {
1352+ // Only the poll this watchdog was armed for, and only while it is
1353+ // still the current one and still in flight. A watchdog whose poll
1354+ // already completed (or was superseded) is a stale timer and must
1355+ // not touch a healthy successor.
1356+ if gen != model.poll_gen || !model.polling {
1357+ return Command::done();
1358+ }
1359+ // Retire it as we give up on it. The request itself is not
1360+ // cancelled — nothing can cancel it — so if it *does* eventually
1361+ // resolve, the generation check below is what stops it from
1362+ // clearing the replacement poll's guard and forking the loop.
1363+ model.abandon_poll();
1364+ Command::event(Event::Poll)
1365+ }
1366+ Event::PollResult(gen, res) => {
1367+ // A result for a poll we already abandoned. Dropping the page is
1368+ // safe — and required. We never wrote a cursor for it, so the
1369+ // relay still holds every message in it and the current poll will
1370+ // be handed them again; whereas honouring it here would clear a
1371+ // guard that now belongs to a *different*, live poll, and the
1372+ // re-poll it chains would be a second loop re-polling itself
1373+ // forever alongside the first.
1374+ if gen != model.poll_gen {
1375+ return Command::done();
1376+ }
6351377 model.polling = false;
1378+ let Ok(mut resp) = res else {
1379+ return self.backoff_poll(model);
1380+ };
6361381 let status = u16::from(resp.status());
6371382 if status == 401 {
6381383 model.token = None;
@@ -642,41 +1387,214 @@ impl App for Skrepka {
6421387 return self.backoff_poll(model);
6431388 }
6441389 model.poll_retries = 0;
1390+ // Ingesting a page into a model with a hole in it is the worst thing
1391+ // this client can do. The cursor write below is what lets the relay
1392+ // delete the mail we just took, so a page ingested into a model that
1393+ // cannot persist it is a page destroyed on both sides at once. Stop
1394+ // the poll loop instead — it restarts on the next launch, which
1395+ // re-runs the loads.
1396+ if model.kv_load_failed {
1397+ model.error = Some(
1398+ "storage read failed — restart the app; messages are not being saved"
1399+ .into(),
1400+ );
1401+ return render();
1402+ }
6451403 let bytes = resp.take_body().unwrap_or_default();
646− let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
1404+ // Bound the response before parsing: a hostile relay can return
1405+ // a multi-gigabyte body that OOMs the app before `ingest_poll`'s
1406+ // per-blob budgets are even consulted.
1407+ if bytes.len() > MAX_POLL_RESPONSE_BYTES {
1408+ model.error = Some("relay sent an oversized poll response".into());
1409+ return self.backoff_poll(model);
1410+ }
1411+ // Q25: A malformed or unparseable poll response must not be
1412+ // treated as an empty page — `unwrap_or_default()` would adopt
1413+ // cursor 0, forcing a re-fetch from the beginning and wasting
1414+ // bandwidth on already-seen (deduped) messages. Back off instead.
1415+ let parsed: PollResp = match serde_json::from_slice(&bytes) {
1416+ Ok(p) => p,
1417+ Err(_) => {
1418+ model.error = Some("relay sent a malformed poll response".into());
1419+ return self.backoff_poll(model);
1420+ }
1421+ };
1422+ let cursor_before = model.cursor;
6471423 let cmd = self.ingest_poll(model, parsed);
648− // Persist cursor and immediately re-poll (the server long-polled 25s).
649− cmd.and(KeyValue::set(K_CURSOR, json_bytes(&model.cursor)).then_send(Event::Saved))
650− .and(Command::event(Event::Poll))
651− .and(render())
652− }
653− Event::PollResult(Err(_)) => {
654− model.polling = false;
655− self.backoff_poll(model)
1424+ // The re-poll hangs off the *cursor write*, not off this event.
1425+ // Polling again is what acks the batch we just took — the server
1426+ // deletes everything up to `cursor` — so it must not happen until
1427+ // the batch is durable on our side. `SavedCursor` re-polls.
1428+ //
1429+ // Arm a watchdog alongside the cursor write: if the shell ever
1430+ // drops the KV effect (suspension, bug), `SavedCursor` never fires,
1431+ // `polling` stays `false`, and the loop is dead until the next
1432+ // foreground `Connect`. The watchdog fires `Poll` if the loop is
1433+ // still idle — same pattern as the poll/send HTTP watchdogs.
1434+ //
1435+ // Skip the cursor write when the cursor did not advance (an empty
1436+ // or fully-deduped page): there is nothing new to persist, so
1437+ // re-poll directly. The watchdog is still armed as a fallback.
1438+ let cursor_advanced = model.cursor != cursor_before;
1439+ cmd.and(if cursor_advanced {
1440+ KeyValue::set(K_CURSOR, json_bytes(&model.cursor))
1441+ .then_send(Event::SavedCursor)
1442+ } else {
1443+ Command::event(Event::Poll)
1444+ })
1445+ .and(
1446+ Time::notify_after(Duration::from_millis(POLL_WATCHDOG_MS))
1447+ .0
1448+ .then_send(|_| Event::Poll),
1449+ )
1450+ .and(render())
6561451 }
6571452
6581453 // ---------------- outbox ----------------
6591454 Event::StartFlush => self.flush_next(model),
660− Event::SendResult(Ok(resp)) => {
1455+ Event::FlushWatchdog(gen) => {
1456+ // Only the flush this watchdog was armed for, and only while it
1457+ // is still the current one and still in flight. A watchdog whose
1458+ // send already completed (or was superseded) is a stale timer and
1459+ // must not touch a healthy successor — same pattern as
1460+ // `PollWatchdog`.
1461+ if gen != model.flush_gen || !model.flushing {
1462+ return Command::done();
1463+ }
1464+ // Retire it as we give up on it. The request itself is not
1465+ // cancelled, so if it eventually resolves the generation check
1466+ // in `SendResult` is what stops it from popping the replacement's
1467+ // items.
1468+ model.abandon_flush();
1469+ self.flush_next(model)
1470+ }
1471+ Event::AuthWatchdog(gen) => {
1472+ // Only the auth this watchdog was armed for, and only while it is
1473+ // still the current one and still in flight.
1474+ if gen != model.auth_gen || !model.authenticating {
1475+ return Command::done();
1476+ }
1477+ model.abandon_auth();
1478+ Command::event(Event::Authenticate)
1479+ }
1480+ Event::SendResult(gen, Ok(resp)) => {
1481+ // A result for a send we already abandoned (a watchdog gave up
1482+ // on it, or a server change invalidated it). Dropping it is safe
1483+ // and required: its items were re-queued, so popping by count
1484+ // here would eat whichever messages slid into their place.
1485+ if gen != model.flush_gen {
1486+ return Command::done();
1487+ }
6611488 // The in-flight send is over; `flush_next` refuses to run while
6621489 // this is set, so it must be cleared before *any* branch below.
6631490 model.flushing = false;
1491+ let batch = model.in_flight.take();
6641492 let status = u16::from(resp.status());
6651493 if status == 401 {
6661494 model.token = None;
1495+ // The batch stays exactly as it is — re-auth, then re-send it.
6671496 return Command::event(Event::Authenticate);
6681497 }
1498+ if status == 429 {
1499+ // Rate-limited. Not a delivery failure and not the items'
1500+ // fault, so they are not charged a retry — a client that spent
1501+ // its delivery budget on the relay's load would drop perfectly
1502+ // good messages for being sent too enthusiastically. They stay
1503+ // queued untouched; we just wait.
1504+ //
1505+ // `Retry-After` is the relay saying how long. Only the
1506+ // delta-seconds form is read — the HTTP-date form falls back to
1507+ // our own backoff — and it is clamped either way, because
1508+ // obeying an unbounded number from a relay is how the outbox
1509+ // gets parked forever.
1510+ let delay = resp
1511+ .header("retry-after")
1512+ .map(|v| v.last().as_str())
1513+ .and_then(|v| v.trim().parse::<u64>().ok())
1514+ .map_or_else(
1515+ || {
1516+ // Nothing to honour, so guess — on the *send* loop's
1517+ // counter, not the poll loop's. `poll_retries` is reset
1518+ // by every page the poll loop takes, so a client polling
1519+ // happily while the relay throttles its sends would
1520+ // restart the send backoff at 3 s on every page, forever.
1521+ // That is not a backoff; it is a fixed 3 s retry dressed
1522+ // as one.
1523+ let ms = backoff_ms(model.send_retries);
1524+ model.send_retries = model.send_retries.saturating_add(1);
1525+ Duration::from_millis(ms)
1526+ },
1527+ |secs| {
1528+ Duration::from_millis(
1529+ secs.saturating_mul(1000).min(MAX_RETRY_AFTER_MS),
1530+ )
1531+ },
1532+ );
1533+ // Park the send loop for the duration, on the *model*. The timer
1534+ // below is not the only thing that fires `StartFlush` — a poll page
1535+ // and a fresh `SendText` both do — and without a pause they walk
1536+ // straight through the wait and hammer the relay that just asked us
1537+ // to stop. `flush_next` refuses to send until this passes, whoever
1538+ // wakes it.
1539+ model.flush_paused_until =
1540+ now_ms().saturating_add(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
1541+ return Time::notify_after(delay)
1542+ .0
1543+ .then_send(|_| Event::StartFlush)
1544+ .and(render());
1545+ }
1546+ if status == 413 {
1547+ // The body outgrew what this relay accepts. `MAX_SEND_BATCH_BYTES`
1548+ // is only a guess at that cap, and this relay's is smaller — so
1549+ // treating the `413` as transient rebuilds the very same batch,
1550+ // which is rejected the very same way, forever.
1551+ //
1552+ // Measure the cap instead: halve what this batch actually
1553+ // weighed and rebuild under that. The items are untouched (we
1554+ // only pop on 200/400), so there is nothing to re-queue.
1555+ //
1556+ // Only a *splittable* batch takes this path. A lone item over
1557+ // the cap cannot be made any smaller, and halving the budget
1558+ // around it would spin — `flush_next` always takes the first
1559+ // item whatever it weighs. It falls through to `retry_batch`,
1560+ // whose budget is what eventually drops it, with an error the
1561+ // user can see.
1562+ if let Some(b) = batch.as_ref().filter(|b| b.count > 1) {
1563+ model.send_batch_budget = Some(b.bytes / 2);
1564+ return self.flush_next(model);
1565+ }
1566+ }
6691567 if (200..300).contains(&status) || status == 400 {
670− // success, or a permanent rejection (self_send/invalid) — drop it.
671− model.outbox.pop_front();
1568+ // The batch was accepted, or rejected outright (`self_send` /
1569+ // `invalid_message` — both permanent, and the relay takes a
1570+ // batch all-or-nothing). Either way it is finished with.
1571+ //
1572+ // The relay is taking our sends again, so the rate-limit backoff
1573+ // has done its job: forget it, or the *next* 429 — possibly hours
1574+ // later, under load that has nothing to do with this one — starts
1575+ // at whatever height the last one climbed to.
1576+ model.send_retries = 0;
1577+ // D3-app: Don't pop if kv is broken — the persist would be
1578+ // refused, and the items would be lost from memory but still
1579+ // on disk, causing duplicate sends on relaunch.
1580+ if !model.kv_load_failed {
1581+ for _ in 0..batch.map_or(0, |b| b.count) {
1582+ model.outbox.pop_front();
1583+ }
1584+ }
6721585 return self.persist_outbox(model).and(self.flush_next(model));
6731586 }
674− // transient error — stop; will resume on next connect/poll.
675− render()
1587+ // A transient failure: a 5xx, a rate limit, a relay restarting.
1588+ self.retry_batch(model, batch)
6761589 }
677− Event::SendResult(Err(_)) => {
1590+ Event::SendResult(gen, Err(_)) => {
1591+ // A transport error is the same transient failure as a 5xx.
1592+ if gen != model.flush_gen {
1593+ return Command::done();
1594+ }
6781595 model.flushing = false;
679− render()
1596+ let batch = model.in_flight.take();
1597+ self.retry_batch(model, batch)
6801598 }
6811599 }
6821600 }
@@ -695,17 +1613,33 @@ impl App for Skrepka {
6951613 ConnStatus::Online => "online",
6961614 };
6971615
1616+ let active_peer = model.active_peer.clone().unwrap_or_default();
1617+
1618+ // `view()` is called after every event the core processes. The
1619+ // per-keystroke optimization below assumes `ComposeChanged` fires on
1620+ // every keystroke, but the Swift shell only sends it before `SendText`
1621+ // — so in practice this path runs once per message, not per keypress.
1622+ // The optimization is still correct (it withholds avatars on the chat
1623+ // page regardless of how often `view` runs), just the original comment
1624+ // overstated the frequency.
1625+ let chat_open = model.page == Page::Chat;
1626+
6981627 let mut contacts: Vec<ContactVM> = model
6991628 .contacts
7001629 .values()
7011630 .map(|c| {
7021631 let msgs = model.messages.get(&c.pubkey);
7031632 let last = msgs.and_then(|m| m.last());
1633+ let photo = if chat_open && c.pubkey != active_peer {
1634+ String::new()
1635+ } else {
1636+ c.photo.clone().unwrap_or_default()
1637+ };
7041638 ContactVM {
7051639 pubkey: c.pubkey.clone(),
7061640 name: c.label(),
7071641 ob: hex_to_ob(&c.pubkey),
708− photo: c.photo.clone().unwrap_or_default(),
1642+ photo,
7091643 blocked: c.blocked,
7101644 last_message: last.map(|m| m.body.clone()).unwrap_or_default(),
7111645 last_ts: last.map_or(0, |m| m.ts),
@@ -714,7 +1648,6 @@ impl App for Skrepka {
7141648 .collect();
7151649 contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
7161650
717− let active_peer = model.active_peer.clone().unwrap_or_default();
7181651 let active_contact = model.contacts.get(&active_peer);
7191652 let messages: Vec<MessageVM> = model
7201653 .messages
@@ -753,28 +1686,96 @@ impl App for Skrepka {
7531686 active_peer_ob: hex_to_ob(&active_peer),
7541687 active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
7551688 messages,
756− compose: model.compose.clone(),
7571689 error: model.error.clone().unwrap_or_default(),
7581690 }
7591691 }
7601692}
7611693
7621694impl Skrepka {
763− fn persist_contacts(&self, model: &Model) -> Command<Effect, Event> {
1695+ /// One of the five startup kv loads landed. Fires `Connect` on the last one.
1696+ ///
1697+ /// Connecting earlier would let a poll land — and be persisted — against a
1698+ /// model that is still missing its contacts, cursor, or outbox. The loads
1699+ /// still in flight would then overwrite what the poll just ingested, and the
1700+ /// stale `cursor` (0) would make us re-fetch mail we had already acked.
1701+ fn startup_done(&self, model: &mut Model) -> Command<Effect, Event> {
1702+ if !model.startup_load_done() {
1703+ return Command::done();
1704+ }
1705+ if model.kv_load_failed {
1706+ // One of the loads could not be read (as opposed to being absent), so
1707+ // the model has a hole in it over data that is still on disk. Do not
1708+ // connect: a poll would ingest mail into that hole and ack it, and the
1709+ // relay would then delete the only copy of messages we never persisted.
1710+ //
1711+ // Everything downstream is already write-guarded, so this is belt and
1712+ // braces — but it is also the only place that can *say* so, and the
1713+ // recovery is a relaunch, which re-runs the loads.
1714+ model.error = Some(
1715+ "storage read failed — restart the app; changes will not be saved until you do"
1716+ .into(),
1717+ );
1718+ return render();
1719+ }
1720+ Command::event(Event::Connect)
1721+ }
1722+
1723+ /// The three model-state writes, and the one rule they share: a model that
1724+ /// failed to read a key must never write one.
1725+ ///
1726+ /// `persist_outbox` is the reason this matters. `ingest_poll` calls it on every
1727+ /// poll page — including an empty one — so a failed outbox read followed by a
1728+ /// single idle poll is enough to write `[]` over a queue of real unsent
1729+ /// messages. The user's mail is destroyed by a client doing nothing at all.
1730+ fn refuse_write(&self, model: &mut Model) -> bool {
1731+ if model.kv_load_failed {
1732+ model.error = Some("storage read failed — changes will not be saved".into());
1733+ return true;
1734+ }
1735+ false
1736+ }
1737+
1738+ fn persist_contacts(&self, model: &mut Model) -> Command<Effect, Event> {
1739+ if self.refuse_write(model) {
1740+ return Command::done();
1741+ }
7641742 let list: Vec<Contact> = model.contacts.values().cloned().collect();
7651743 KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
7661744 }
7671745
768− fn persist_outbox(&self, model: &Model) -> Command<Effect, Event> {
1746+ fn persist_profile(&self, model: &mut Model) -> Command<Effect, Event> {
1747+ if self.refuse_write(model) {
1748+ return Command::done();
1749+ }
1750+ KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved)
1751+ }
1752+
1753+ fn persist_outbox(&self, model: &mut Model) -> Command<Effect, Event> {
1754+ if self.refuse_write(model) {
1755+ return Command::done();
1756+ }
7691757 let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
7701758 KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
7711759 }
7721760
773− fn persist_messages(&self, model: &Model, peer: &str) -> Command<Effect, Event> {
1761+ fn persist_messages(&self, model: &mut Model, peer: &str) -> Command<Effect, Event> {
1762+ if self.refuse_write(model) {
1763+ return Command::done();
1764+ }
7741765 let list = model.messages.get(peer).cloned().unwrap_or_default();
7751766 KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
7761767 }
7771768
1769+ /// Q18: Persist the per-peer dedup ID set. Written alongside messages so
1770+ /// the set survives restarts and covers IDs that `trim_history` has aged
1771+ /// out of the conversation.
1772+ fn persist_seen_ids(&self, model: &mut Model) -> Command<Effect, Event> {
1773+ if self.refuse_write(model) {
1774+ return Command::done();
1775+ }
1776+ KeyValue::set(K_SEEN_IDS, json_bytes(&model.seen_ids)).then_send(Event::Saved)
1777+ }
1778+
7781779 fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
7791780 let delay = backoff_ms(model.poll_retries);
7801781 model.poll_retries = model.poll_retries.saturating_add(1);
@@ -784,7 +1785,15 @@ impl Skrepka {
7841785 .and(render())
7851786 }
7861787
1788+ /// The poll loop stalled — retry after a backoff, and stop claiming to be online.
1789+ ///
1790+ /// Every path here (a transport error, a 5xx, a failed cursor write) means the loop
1791+ /// is not healthy, so leaving `conn` at `Online` both lies to the status dot and —
1792+ /// worse — makes `Connect` a no-op, because it stands down when it already believes
1793+ /// it is online. The shell fires `Connect` on every foreground precisely to recover
1794+ /// the long-poll that backgrounding killed; without this it recovers nothing.
7871795 fn backoff_poll(&self, model: &mut Model) -> Command<Effect, Event> {
1796+ model.conn = ConnStatus::Offline;
7881797 let delay = backoff_ms(model.poll_retries);
7891798 model.poll_retries = model.poll_retries.saturating_add(1);
7901799 Time::notify_after(Duration::from_millis(delay))
@@ -797,47 +1806,95 @@ impl Skrepka {
7971806 let Some(peer) = model.active_peer.clone() else {
7981807 return render();
7991808 };
1809+ // Clear a stale error from a previous failed send, so the error display
1810+ // does not persist after the user fixes the issue and sends again.
1811+ model.error = None;
1812+ // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1813+ // ingest side drops their messages; the send side must refuse too, or
1814+ // a blocked peer still receives our texts, acks, and the liveness
1815+ // signal that blocking is supposed to cut off.
1816+ if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1817+ model.error = Some("cannot send to a blocked contact".into());
1818+ return render();
1819+ }
8001820 let body = model.compose.trim().to_string();
8011821 if body.is_empty() {
8021822 return render();
8031823 }
1824+ // The recipient's `parse_payload` enforces `MAX_BODY_LEN` and silently
1825+ // drops oversized text. Without this check the message would be stored
1826+ // locally, sent to the relay, and marked "sent" — but never arrive, never
1827+ // get acked, and the sender would see it stuck as "sent" forever with no
1828+ // error.
1829+ if body.len() > protocol::MAX_BODY_LEN {
1830+ model.error = Some("message too long".into());
1831+ return render();
1832+ }
1833+
1834+ // Check the contact cap *before* mutating anything. The old order
1835+ // cleared `compose` and inserted the message into `model.messages`
1836+ // first, then hit the cap and returned early — so the user's text was
1837+ // gone (compose cleared), the message sat in an invisible orphan
1838+ // conversation (no contact entry to click on), and nothing was
1839+ // persisted (no `persist_messages` before the early return). On next
1840+ // launch the message was silently lost.
1841+ let was_new = !model.contacts.contains_key(&peer);
1842+ if was_new && model.contacts.len() >= MAX_CONTACTS {
1843+ model.error = Some("contact list is full".into());
1844+ return render();
1845+ }
1846+
8041847 model.compose.clear();
8051848 let ts = now_ms();
8061849 let id = uuid::Uuid::new_v4().to_string();
8071850
8081851 let convo = model.messages.entry(peer.clone()).or_default();
809− let first_to_peer = !convo.iter().any(|m| m.outgoing);
810− convo.push(StoredMessage {
811− id: id.clone(),
812− body: body.clone(),
813− ts,
814− outgoing: true,
815− delivered: false,
816− });
1852+ // Insert in order rather than appending: an incoming message may hold a
1853+ // `ts` up to `MAX_FUTURE_SKEW_MS` ahead of ours, so appending would leave
1854+ // the conversation unsorted — and `trim_history` ages out the *front*.
1855+ insert_sorted(
1856+ convo,
1857+ StoredMessage {
1858+ id: id.clone(),
1859+ body: body.clone(),
1860+ ts,
1861+ outgoing: true,
1862+ delivered: false,
1863+ },
1864+ );
8171865 trim_history(convo);
8181866
819− // Auto-create a bare contact if messaging a brand-new key.
820− model
821− .contacts
822− .entry(peer.clone())
823− .or_insert_with(|| Contact::new(peer.clone(), String::new(), ts));
1867+ // Auto-create a bare contact if messaging a brand-new key, respecting
1868+ // the same `MAX_CONTACTS` cap `ingest_poll` enforces. This is
1869+ // user-driven (not an attack vector), but the cap exists to bound the
1870+ // `contacts` kv blob, which is rewritten in full on every change.
1871+ if was_new {
1872+ model
1873+ .contacts
1874+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), ts));
1875+ }
8241876
8251877 let text = Payload::Text { id, body };
826− model.outbox.push_back(OutboxItem {
827− recipient: peer.clone(),
828− envelope_json: protocol::serialize_payload(&text, ts),
829− });
830− // On first contact, also share our profile.
831− if first_to_peer {
1878+ model.outbox.push_back(OutboxItem::new(
1879+ peer.clone(),
1880+ Arc::new(protocol::serialize_payload(&text, ts)),
1881+ ));
1882+
1883+ // On first contact, share our profile *after* the text. The text
1884+ // auto-creates the contact on the recipient's side; the profile that
1885+ // follows in the same batch then applies to the now-existing contact.
1886+ // (Previously profile was queued before the text, but the recipient's
1887+ // ingest drops profiles from non-contacts, so the profile was lost.)
1888+ if was_new {
8321889 let profile = Payload::Profile {
833− display_name: model.profile.display_name.clone(),
834− bio: model.profile.bio.clone(),
1890+ display_name: Some(model.profile.display_name.clone()),
1891+ bio: Some(model.profile.bio.clone()),
8351892 photo: model.profile.photo.clone(),
8361893 };
837− model.outbox.push_back(OutboxItem {
838− recipient: peer.clone(),
839− envelope_json: protocol::serialize_payload(&profile, ts),
840− });
1894+ model.outbox.push_back(OutboxItem::profile(
1895+ peer.clone(),
1896+ Arc::new(protocol::serialize_payload(&profile, ts)),
1897+ ));
8411898 }
8421899
8431900 Command::all([
@@ -849,89 +1906,292 @@ impl Skrepka {
8491906 .and(render())
8501907 }
8511908
1909+ /// Encrypt and send a batch from the head of the outbox, discarding any
1910+ /// unsendable items in the way.
1911+ ///
1912+ /// The batch is the longest run of items at the head that share a recipient,
1913+ /// bounded by `MAX_SEND_BATCH` and `MAX_SEND_BATCH_BYTES`. Sending them one
1914+ /// at a time meant an HTTP round-trip per message — and per *ack*, which
1915+ /// `ingest_poll` queues one of per sender per page, so catching up after a
1916+ /// long offline stretch was one request per message received. The relay takes
1917+ /// up to a hundred per request (PROTOCOL.md §7); take it up on that.
1918+ ///
1919+ /// Stopping at the first recipient change keeps the outbox's FIFO ordering
1920+ /// intact — a batch is delivered as a unit, so hoisting a later item for the
1921+ /// same peer over an earlier one for a different peer could only reorder
1922+ /// *across* conversations, but it would also let one busy peer starve the
1923+ /// rest of the queue. Consecutive-only is the cheap, obviously-fair rule, and
1924+ /// it already collapses the two cases that matter: a burst of texts to the
1925+ /// open chat, and a page's worth of acks back to one sender.
1926+ ///
1927+ /// The discards happen in one pass, followed by a single `persist_outbox`.
1928+ /// The previous shape recursed once per bad item and emitted a full outbox
1929+ /// rewrite each time, so a contact list holding `n` unusable keys cost
1930+ /// O(n) kv writes of an O(n) blob — and `n` stack frames — to reach the first
1931+ /// item that could actually go out.
8521932 fn flush_next(&self, model: &mut Model) -> Command<Effect, Event> {
8531933 if model.flushing {
8541934 return Command::done();
8551935 }
856− let Some(item) = model.outbox.front().cloned() else {
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 {
8571941 return Command::done();
858− };
1942+ }
1943+ // A 429 parked the loop until the far side of the relay's `Retry-After`.
1944+ // The timer that set the pause also scheduled the `StartFlush` that resumes
1945+ // us, so whoever woke us early — a poll page, a fresh `SendText` — the
1946+ // answer is the same: the relay asked us to stop until then.
1947+ if now_ms() < model.flush_paused_until {
1948+ return Command::done();
1949+ }
8591950 let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
8601951 return Command::done();
8611952 };
862− // An unusable recipient key can never be encrypted to, so the item has to
863− // be dropped or it blocks the head of the outbox forever — but say so,
864− // rather than letting the message vanish.
865− let drop_item = |model: &mut Model| {
866− model.error = Some(format!(
867− "cannot send to {}: invalid key",
868− trunc_ob(&item.recipient)
869− ));
870− model.outbox.pop_front();
871− };
8721953
873− let Ok(recipient) = hex::decode(&item.recipient) else {
874− drop_item(model);
875− return self.persist_outbox(model).and(self.flush_next(model));
876− };
877− let mut recip = [0u8; 32];
878− if recipient.len() != 32 {
879− drop_item(model);
880− return self.persist_outbox(model).and(self.flush_next(model));
881− }
882− recip.copy_from_slice(&recipient);
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);
8831962
884− let mut rng = rand_core::OsRng;
885− let blob = match crate::crypto::encrypt(&mut rng, &id, &recip, item.envelope_json.as_bytes())
886− {
887− // Reachable for a 32-byte key that isn't a curve point: `AddContact`
888− // rejects those now, but an older contact list may still hold one.
889− Ok(b) => b,
890− Err(_) => {
891− drop_item(model);
892− return self.persist_outbox(model).and(self.flush_next(model));
1963+ while messages.len() < MAX_SEND_BATCH {
1964+ let Some(item) = model.outbox.get(messages.len()) else {
1965+ break;
1966+ };
1967+ if recipient.as_ref().is_some_and(|r| r != &item.recipient) {
1968+ break;
8931969 }
1970+ let Some(blob) = encrypt_for(&id, item) else {
1971+ // An unusable recipient key can never be encrypted to, so the item
1972+ // has to go or it blocks the head of the outbox forever — but say
1973+ // so, rather than letting the message vanish. (Only reachable at
1974+ // the head: an item deeper in the batch shares the recipient of one
1975+ // we have already encrypted to, so its key is good by construction.)
1976+ model.error = Some(format!(
1977+ "cannot send to {}: invalid key",
1978+ trunc_ob(&item.recipient)
1979+ ));
1980+ model.outbox.remove(messages.len());
1981+ dirty = true;
1982+ continue;
1983+ };
1984+ let hex_blob = hex::encode(blob);
1985+ // Always take the first item, whatever it weighs — a lone oversized
1986+ // payload must still reach the relay, which rejects it permanently
1987+ // (`400`) and so gets it out of the queue for good. Past the first,
1988+ // stop before the body outgrows what a relay will accept.
1989+ if !messages.is_empty() && bytes + hex_blob.len() > batch_budget {
1990+ break;
1991+ }
1992+ bytes += hex_blob.len();
1993+ recipient = Some(item.recipient.clone());
1994+ messages.push(Envelope {
1995+ to: item.recipient.clone(),
1996+ encrypted_blob: hex_blob,
1997+ });
1998+ }
1999+
2000+ let Some(recipient) = recipient else {
2001+ // Nothing left to send. Render only if we dropped something, so the
2002+ // user sees why their message went away.
2003+ return if dirty {
2004+ self.persist_outbox(model).and(render())
2005+ } else {
2006+ Command::done()
2007+ };
8942008 };
895−
896− model.flushing = true;
897− let batch = SendBatch {
898− messages: vec![Envelope {
899− to: item.recipient,
900− encrypted_blob: hex::encode(blob),
901− }],
902− };
2009+
2010+ // Stamp the items now going out for the first time. This is what the TTL
2011+ // in `OutboxItem::is_expired` measures from, so it has to be durable —
2012+ // otherwise a relaunch resets the clock and a permanently-stuck item is
2013+ // immortal again.
2014+ for item in model.outbox.iter_mut().take(messages.len()) {
2015+ if item.first_attempt == 0 {
2016+ item.first_attempt = now;
2017+ dirty = true;
2018+ }
2019+ }
2020+ let persist = if dirty {
2021+ self.persist_outbox(model)
2022+ } else {
2023+ Command::done()
2024+ };
2025+
2026+ model.flushing = true;
2027+ let gen = model.next_flush_gen();
2028+ model.in_flight = Some(InFlight {
2029+ recipient,
2030+ count: messages.len(),
2031+ bytes,
2032+ });
9032033 let url = format!("{}/messages", model.settings.server_url);
9042034 match Http::post(url)
9052035 .header("authorization", format!("Bearer {token}"))
906− .body_json(&batch)
2036+ .body_json(&SendBatch { messages })
9072037 {
908− Ok(req) => req.build().then_send(Event::SendResult),
2038+ Ok(req) => persist.and(req.build().then_send(move |r| Event::SendResult(gen, r)))
2039+ .and(
2040+ Time::notify_after(Duration::from_millis(FLUSH_WATCHDOG_MS))
2041+ .0
2042+ .then_send(move |_| Event::FlushWatchdog(gen)),
2043+ ),
9092044 Err(_) => {
9102045 // Not a dead loop: the next StartFlush (from a poll page or a
9112046 // fresh send) retries the head of the outbox.
9122047 model.flushing = false;
2048+ model.in_flight = None;
9132049 model.error = Some("could not build send request".into());
914− render()
2050+ persist.and(render())
9152051 }
9162052 }
9172053 }
9182054
2055+ /// A send failed transiently (a 5xx, a rate limit, a dropped connection). The
2056+ /// batch stays queued and goes out again on the next `StartFlush` — but not
2057+ /// forever.
2058+ ///
2059+ /// The outbox is a strict FIFO, so an item that can never be delivered blocks
2060+ /// every message queued behind it, permanently: a single unreachable recipient
2061+ /// relay used to be enough to silence the client for good. Charge every item in
2062+ /// the failed batch a retry and drop the ones that have exhausted their budget
2063+ /// (`OutboxItem::is_expired`) — with an error, rather than letting them vanish.
2064+ fn retry_batch(&self, model: &mut Model, batch: Option<InFlight>) -> Command<Effect, Event> {
2065+ // `None` means the items were removed out from under the send (the peer was
2066+ // blocked, or deleted). There is nothing left to charge or to drop.
2067+ let Some(batch) = batch else {
2068+ return render();
2069+ };
2070+
2071+ let now = now_ms();
2072+ let mut expired = 0usize;
2073+ let mut kept: VecDeque<OutboxItem> = VecDeque::with_capacity(model.outbox.len());
2074+ for (i, mut item) in std::mem::take(&mut model.outbox).into_iter().enumerate() {
2075+ if i < batch.count {
2076+ item.retries = item.retries.saturating_add(1);
2077+ if item.is_expired(now) {
2078+ expired += 1;
2079+ continue;
2080+ }
2081+ }
2082+ kept.push_back(item);
2083+ }
2084+ model.outbox = kept;
2085+
2086+ if expired == 0 {
2087+ // Nothing dropped, but the retry counters still have to be durable: a
2088+ // relaunch that handed every stuck item a fresh budget would wedge the
2089+ // outbox across restarts exactly as it did before there was a budget.
2090+ return self.persist_outbox(model).and(render());
2091+ }
2092+ model.error = Some(format!(
2093+ "gave up sending {expired} message(s) to {}",
2094+ trunc_ob(&batch.recipient)
2095+ ));
2096+ // The head of the outbox has moved, so whatever was stuck behind the items
2097+ // we just dropped can finally go out.
2098+ self.persist_outbox(model)
2099+ .and(self.flush_next(model))
2100+ .and(render())
2101+ }
2102+
9192103 /// Decrypt and fold a poll page into the model; queue delivery acks.
9202104 fn ingest_poll(&self, model: &mut Model, page: PollResp) -> Command<Effect, Event> {
921− if page.cursor > model.cursor {
922− model.cursor = page.cursor;
2105+ // The caller already refuses to poll into a broken model, and must — the
2106+ // cursor write is its own, and advancing it is what acks the page. This
2107+ // stands guard for whoever calls this next: ingesting is only safe if what
2108+ // is ingested can be written down.
2109+ if model.kv_load_failed {
2110+ return Command::done();
2111+ }
2112+ let prev_cursor = model.cursor;
2113+ // Clamp the relay-supplied cursor: honest cursors are wall-clock-seeded
2114+ // sequence numbers, so anything far in the future is a hostile or buggy
2115+ // relay. Adopting `i64::MAX` would ack (delete) all queued mail on the
2116+ // next poll and persist that poison across relaunches.
2117+ let clamped_cursor = page.cursor.min(now_ms().saturating_add(60_000));
2118+ // Q16: Only advance the cursor when the page actually carried events.
2119+ // An empty page with a cursor higher than ours is suspicious — a
2120+ // compromised relay could poison the cursor to ack (delete) messages
2121+ // the client hasn't seen. When there are no events, there is nothing
2122+ // to acknowledge, so the cursor should not move. (An honest relay's
2123+ // empty-page cursor equals what the client sent, so this is a no-op
2124+ // in the normal case.)
2125+ if !page.events.is_empty() && clamped_cursor > model.cursor {
2126+ model.cursor = clamped_cursor;
9232127 }
9242128 let Some(id) = model.identity() else {
9252129 return Command::done();
9262130 };
9272131
9282132 let now = now_ms();
929− let mut touched_convos: Vec<String> = Vec::new();
2133+ let mut touched_convos: HashSet<String> = HashSet::new();
9302134 let mut contacts_dirty = false;
2135+ let mut outbox_dirty = false;
9312136 // sender hex -> message ids needing acks
9322137 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();
9332142
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;
9342153 for ev in page.events {
2154+ // Reject an oversized blob on its hex length, before it costs us a
2155+ // decode. No conforming sender produces one (the relay caps blobs at
2156+ // the same size), so this can only be junk.
2157+ if ev.encrypted_blob.len() > MAX_BLOB_LEN * 2 {
2158+ continue;
2159+ }
2160+ // Event count cap: the byte budget alone permits ~388K minimum-size
2161+ // events, each costing a full decrypt attempt. A hostile relay can
2162+ // freeze the app for tens of seconds per poll with a page of small
2163+ // junk blobs. Stop processing past this count.
2164+ if events_processed >= MAX_POLL_EVENTS {
2165+ // N9: If we stored at least one event, keep the advanced cursor
2166+ // to ack the page and avoid a livelock. The unprocessed events
2167+ // are hostile junk or beyond our capacity — we can't process
2168+ // them anyway, and re-fetching them forever would wedge the
2169+ // client.
2170+ if events_stored == 0 {
2171+ model.cursor = prev_cursor;
2172+ }
2173+ model.error = Some("relay sent too many poll events".into());
2174+ break;
2175+ }
2176+ events_processed += 1;
2177+ match budget.checked_sub(ev.encrypted_blob.len() / 2) {
2178+ Some(left) => budget = left,
2179+ None => {
2180+ // N9: Budget exhausted. If we stored at least one event,
2181+ // keep the advanced cursor to ack the page — re-fetching
2182+ // an oversized page forever is a livelock. The unprocessed
2183+ // events are beyond our capacity and would be re-served
2184+ // on every poll, wedging the client. Acking them breaks
2185+ // the loop. If zero events were stored (completely hostile
2186+ // page), restore the cursor to avoid acking mail we
2187+ // never saw.
2188+ if events_stored == 0 {
2189+ model.cursor = prev_cursor;
2190+ }
2191+ model.error = Some("relay sent an oversized poll page".into());
2192+ break;
2193+ }
2194+ }
9352195 let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
9362196 continue;
9372197 };
@@ -941,91 +2201,261 @@ impl Skrepka {
9412201 let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
9422202 continue;
9432203 };
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;
9442208 let sender = dec.sender_hex;
2209+
2210+ // Mail from ourselves. A relay cannot forge this — the sender identity
2211+ // is signed inside the AEAD — but it can *echo back* a blob we sent
2212+ // through it, and one addressed to ourselves decrypts perfectly well.
2213+ // Nothing downstream would notice: it would auto-create a contact for
2214+ // our own key (which `AddContact` explicitly refuses to do), file our
2215+ // own words as an incoming message, and queue a delivery ack addressed
2216+ // to us — which flushes, comes back, and is acked again.
2217+ if sender == model.my_pubkey {
2218+ continue;
2219+ }
2220+
2221+ // Blocking cuts the peer off entirely (PROTOCOL.md §4) — not just
2222+ // their texts. A blocked peer must not be able to rewrite their
2223+ // display name and avatar in our contact list, mark our messages
2224+ // delivered, or (via an ack we send back) learn that we are online
2225+ // and reading. So the gate sits above the payload match, not inside
2226+ // the `Text` arm.
2227+ if model.contacts.get(&sender).is_some_and(|c| c.blocked) {
2228+ continue;
2229+ }
2230+
9452231 // `ts` is chosen by the sender and drives conversation ordering,
9462232 // in-chat ordering, and the displayed time. Bound it to the near
9472233 // future so a peer cannot pin itself to the top of the list forever
948− // with `ts = i64::MAX`.
949− let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
950− let blocked = model.contacts.get(&sender).is_some_and(|c| c.blocked);
2234+ // with `ts = i64::MAX`, and bound it to the non-negative past so a
2235+ // malicious peer cannot make their own message invisible by dating
2236+ // it at `i64::MIN` — `insert_sorted` would put it at position 0 and
2237+ // `trim_history` would age it out first.
2238+ let ts = parsed
2239+ .ts
2240+ .max(0)
2241+ .min(now.saturating_add(MAX_FUTURE_SKEW_MS));
2242+ let known = model.contacts.contains_key(&sender);
9512243 match parsed.payload {
9522244 Payload::Text { id: msg_id, body } => {
953− // A blocked peer's messages are not displayed (PROTOCOL.md
954− // §4), so don't store them — and don't ack them either: an
955− // ack would tell them we are online and reading.
956− if blocked {
957− continue;
958− }
959− // auto-create contact
960− if !model.contacts.contains_key(&sender) {
961− model
962− .contacts
963− .insert(sender.clone(), Contact::new(sender.clone(), String::new(), ts));
2245+ // Auto-create a contact for a stranger who writes to us — but
2246+ // only up to `MAX_CONTACTS`. Past that, drop the message
2247+ // entirely: storing it would mean a `messages:<peer>` blob for
2248+ // a peer with no contact entry, i.e. a conversation nothing
2249+ // can open and nothing ever trims.
2250+ if !known {
2251+ if model.contacts.len() >= MAX_CONTACTS {
2252+ continue;
2253+ }
2254+ model.contacts.insert(
2255+ sender.clone(),
2256+ Contact::new(sender.clone(), String::new(), ts),
2257+ );
9642258 contacts_dirty = true;
2259+ // D6-app: Send our profile to the new contact so they
2260+ // see our display name and avatar, not just our @p.
2261+ let profile = Payload::Profile {
2262+ display_name: Some(model.profile.display_name.clone()),
2263+ bio: Some(model.profile.bio.clone()),
2264+ photo: model.profile.photo.clone(),
2265+ };
2266+ model.outbox.push_back(OutboxItem::profile(
2267+ sender.clone(),
2268+ Arc::new(protocol::serialize_payload(&profile, ts)),
2269+ ));
2270+ outbox_dirty = true;
9652271 }
9662272 let convo = model.messages.entry(sender.clone()).or_default();
967− // dedup by id
968− if !convo.iter().any(|m| m.id == msg_id) {
969− convo.push(StoredMessage {
970− id: msg_id.clone(),
971− body,
972− ts,
973− outgoing: false,
974− delivered: false,
975− });
976− convo.sort_by_key(|m| m.ts);
977− trim_history(convo);
978− if !touched_convos.contains(&sender) {
979− touched_convos.push(sender.clone());
2273+ // Q18: Use the persistent per-peer dedup set from
2274+ // `model.seen_ids` (loaded from kv at startup) instead of
2275+ // deriving it from the trimmed conversation. Without this,
2276+ // a replayed blob whose `id` was aged out by `trim_history`
2277+ // (past the 1000-message cap) passes the dedup check and
2278+ // re-enters the conversation.
2279+ let id_set = model
2280+ .seen_ids
2281+ .entry(sender.clone())
2282+ .or_default();
2283+ // Also seed from the conversation in case the kv load was
2284+ // missing or partial — covers the upgrade path.
2285+ if id_set.is_empty() {
2286+ id_set.extend(convo.iter().map(|m| m.id.clone()));
2287+ }
2288+ // dedup by id — O(1) via HashSet
2289+ if !id_set.contains(&msg_id) {
2290+ // The conversation is *kept* sorted rather than re-sorted:
2291+ // a `sort_by_key` per arrival is O(n log n) on a list of up
2292+ // to `MAX_MESSAGES_PER_PEER`, every time, and a poll page
2293+ // can carry hundreds of messages.
2294+ insert_sorted(
2295+ convo,
2296+ StoredMessage {
2297+ id: msg_id.clone(),
2298+ body,
2299+ ts,
2300+ outgoing: false,
2301+ delivered: false,
2302+ },
2303+ );
2304+ id_set.insert(msg_id.clone());
2305+ // N7: Cap the dedup set so it doesn't grow without
2306+ // bound. The set is rewritten to kv on every message
2307+ // receipt, so an unbounded set makes every receipt
2308+ // slower. Keep the most recent IDs by draining excess
2309+ // from the oldest. `HashSet` has no ordering, so when
2310+ // the cap is exceeded we rebuild from the conversation
2311+ // (which is trimmed to MAX_MESSAGES_PER_PEER and
2312+ // sorted by ts) — the oldest IDs in the set that are
2313+ // no longer in the conversation are the ones to drop.
2314+ if id_set.len() > MAX_SEEN_IDS_PER_PEER {
2315+ let convo_ids: HashSet<&String> =
2316+ convo.iter().map(|m| &m.id).collect();
2317+ let excess = id_set.len() - MAX_SEEN_IDS_PER_PEER;
2318+ let to_remove: Vec<String> = id_set
2319+ .iter()
2320+ .filter(|id| !convo_ids.contains(*id))
2321+ .take(excess)
2322+ .cloned()
2323+ .collect();
2324+ for id in to_remove {
2325+ id_set.remove(&id);
2326+ }
9802327 }
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);
9812333 }
982− ack_targets.entry(sender).or_default().push(msg_id);
9832334 }
9842335 Payload::DeliveryAck { ack_ids } => {
985− if let Some(convo) = model.messages.get_mut(&sender) {
986− let mut changed = false;
987− for m in convo.iter_mut() {
988− if m.outgoing && ack_ids.contains(&m.id) && !m.delivered {
989− m.delivered = true;
990− changed = true;
991− }
992− }
993− if changed && !touched_convos.contains(&sender) {
994− touched_convos.push(sender);
995− }
2336+ // Q17: Defer the staleness check until after the event loop.
2337+ // Within a single page, a relay can reorder events so that a
2338+ // newer ack (higher ts) is processed before an older one. If
2339+ // we applied the watermark immediately, the older ack would be
2340+ // dropped as stale — even if it acks different message IDs.
2341+ // Instead, accumulate all ack_ids per sender and the max ts,
2342+ // then apply them in a second pass below the loop.
2343+ let prev_ts = pending_acks
2344+ .get(&sender)
2345+ .map(|p| p.ts)
2346+ .unwrap_or(0);
2347+ if ts > prev_ts {
2348+ let entry = pending_acks.entry(sender.clone()).or_default();
2349+ entry.ts = ts;
9962350 }
2351+ pending_acks
2352+ .entry(sender.clone())
2353+ .or_default()
2354+ .ids
2355+ .extend(ack_ids);
9972356 }
9982357 Payload::Profile {
9992358 display_name,
10002359 bio,
10012360 photo,
10022361 } => {
1003− let c = model
1004− .contacts
1005− .entry(sender.clone())
1006− .or_insert_with(|| Contact::new(sender.clone(), String::new(), ts));
1007− // Ignore stale profile replays (PROTOCOL.md §4). The clamped
1008− // `ts` is what gets recorded, so a peer cannot park
1009− // `last_profile_ts` at `i64::MAX` and freeze its own profile.
2362+ // A bare profile from a stranger creates nothing. It is an
2363+ // unsolicited push with no message attached, so honouring it
2364+ // would let anyone holding our pubkey put an entry (with an
2365+ // avatar) in our contact list without ever saying a word.
2366+ let Some(c) = model.contacts.get_mut(&sender) else {
2367+ continue;
2368+ };
2369+ // Ignore stale profile replays (PROTOCOL.md §4). The watermark
2370+ // is clamped to *now* for the same reason `last_ack_ts` is: a
2371+ // peer that parked it in the future would freeze its own
2372+ // profile, since every honest update it sent afterwards would
2373+ // read as a stale replay of itself.
2374+ //
2375+ // `>=` (not `>`): a contact's `last_profile_ts` starts at 0,
2376+ // and a first profile whose `ts` is also 0 — the default when
2377+ // the field is missing — must still be accepted. `>` would
2378+ // silently drop it, leaving the contact with no profile at all.
2379+ // A replayed profile at the same `ts` overwrites with identical
2380+ // content, which is a no-op in practice.
10102381 if ts >= c.last_profile_ts {
1011− c.display_name = display_name;
1012− c.bio = bio;
1013− c.photo = photo;
1014− c.last_profile_ts = ts;
2382+ // D13: Only update fields that are present (Some).
2383+ // An absent field (None) means "don't change".
2384+ if let Some(name) = display_name {
2385+ c.display_name = name;
2386+ }
2387+ if let Some(b) = bio {
2388+ c.bio = b;
2389+ }
2390+ if let Some(p) = photo {
2391+ c.photo = Some(p);
2392+ }
2393+ c.last_profile_ts = ts.min(now);
10152394 contacts_dirty = true;
10162395 }
10172396 }
10182397 }
10192398 }
10202399
1021− // Queue delivery acks (one payload per sender).
2400+ // Q17: Apply deferred delivery acks. Collected during the event loop
2401+ // above, now applied with a single staleness check per sender using the
2402+ // highest `ts` seen — so relay page ordering cannot cause an older ack
2403+ // to be dropped as stale by a newer one processed first.
2404+ for (sender, pending) in &pending_acks {
2405+ let stale = model
2406+ .contacts
2407+ .get(sender)
2408+ .is_some_and(|c| pending.ts < c.last_ack_ts);
2409+ if stale {
2410+ continue;
2411+ }
2412+ // The watermark is clamped to *now*, not merely into the skew window:
2413+ // `ts` is the peer's to choose, and `last_ack_ts` is what every later
2414+ // ack is measured against. A single ack at `ts = i64::MAX` would
2415+ // otherwise park the watermark a minute into the future, and every
2416+ // honest ack that followed — the ones that actually mark our messages
2417+ // delivered — would read as stale and be dropped.
2418+ if let Some(c) = model.contacts.get_mut(sender) {
2419+ c.last_ack_ts = pending.ts.min(now);
2420+ contacts_dirty = true;
2421+ }
2422+ if let Some(convo) = model.messages.get_mut(sender) {
2423+ let mut changed = false;
2424+ for m in convo.iter_mut() {
2425+ if m.outgoing && pending.ids.contains(&m.id) && !m.delivered {
2426+ m.delivered = true;
2427+ changed = true;
2428+ }
2429+ }
2430+ if changed {
2431+ touched_convos.insert(sender.clone());
2432+ }
2433+ }
2434+ }
2435+
2436+ // Queue delivery acks, in batches the recipient will actually accept.
2437+ //
2438+ // `parse_payload` drops a `delivery.ack` carrying more than `MAX_ACK_IDS`
2439+ // ids, and a peer running this same code applies that cap to *us*. One
2440+ // payload per sender therefore silently stops working the moment a peer
2441+ // has more than `MAX_ACK_IDS` messages waiting for us — which is exactly
2442+ // what a long offline stretch produces — and every one of those messages
2443+ // stays un-delivered on their side forever, because a poll page is only
2444+ // ever acked once.
10222445 let ack_ts = now_ms();
10232446 for (sender, ids) in ack_targets {
1024− let payload = Payload::DeliveryAck { ack_ids: ids };
1025− model.outbox.push_back(OutboxItem {
1026− recipient: sender,
1027− envelope_json: protocol::serialize_payload(&payload, ack_ts),
1028− });
2447+ for batch in ids.chunks(protocol::MAX_ACK_IDS) {
2448+ let payload = Payload::DeliveryAck {
2449+ ack_ids: batch.to_vec(),
2450+ };
2451+ // Consecutive and same-recipient, so `flush_next` puts every one of
2452+ // a sender's ack batches into a single `/messages` request.
2453+ model.outbox.push_back(OutboxItem::new(
2454+ sender.clone(),
2455+ Arc::new(protocol::serialize_payload(&payload, ack_ts)),
2456+ ));
2457+ }
2458+ outbox_dirty = true;
10292459 }
10302460
10312461 // Persist everything touched.
@@ -1033,19 +2463,49 @@ impl Skrepka {
10332463 for peer in &touched_convos {
10342464 cmds.push(self.persist_messages(model, peer));
10352465 }
2466+ // Q18: Persist the dedup ID set whenever we touched conversations.
2467+ if !touched_convos.is_empty() {
2468+ cmds.push(self.persist_seen_ids(model));
2469+ }
10362470 if contacts_dirty {
10372471 cmds.push(self.persist_contacts(model));
10382472 }
1039− cmds.push(self.persist_outbox(model));
2473+ if outbox_dirty {
2474+ cmds.push(self.persist_outbox(model));
2475+ }
10402476 cmds.push(Command::event(Event::StartFlush));
10412477 Command::all(cmds)
10422478 }
10432479}
10442480
2481+/// Encrypt one outbox item to its recipient, or `None` if the recipient's key is
2482+/// unusable — a bad hex string, the wrong length, or 32 bytes that aren't a curve
2483+/// point. `AddContact` rejects all three now, but an older contact list (or a
2484+/// profile-broadcast fan-out over one) may still hold such a key.
2485+fn encrypt_for(sender: &Identity, item: &OutboxItem) -> Option<Vec<u8>> {
2486+ let recipient: [u8; 32] = hex::decode(&item.recipient).ok()?.try_into().ok()?;
2487+ let mut rng = rand_core::OsRng;
2488+ crate::crypto::encrypt(&mut rng, sender, &recipient, item.envelope_json.as_bytes()).ok()
2489+}
2490+
10452491fn backoff_ms(retries: u32) -> u64 {
10462492 (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
10472493}
10482494
2495+/// Insert a message into a conversation held in `ts` order.
2496+///
2497+/// `partition_point` lands *after* every message sharing the new one's `ts`, so
2498+/// ties keep arrival order — exactly what the stable `sort_by_key` this replaces
2499+/// produced, at O(log n) + a shift instead of O(n log n) on every message.
2500+///
2501+/// This is what makes "sorted by `ts`" an invariant rather than something
2502+/// `ingest_poll` restored after the fact, which is what `trim_history` has always
2503+/// assumed (it ages out the *front*) and what `view()` renders in order.
2504+fn insert_sorted(convo: &mut Vec<StoredMessage>, msg: StoredMessage) {
2505+ let pos = convo.partition_point(|m| m.ts <= msg.ts);
2506+ convo.insert(pos, msg);
2507+}
2508+
10492509/// Age out local history: keep only the most recent `MAX_MESSAGES_PER_PEER`
10502510/// messages of a conversation (PROTOCOL.md §9). Callers keep `convo` sorted by
10512511/// `ts`, so the oldest messages are at the front. The subsequent kv write
@@ -1056,33 +2516,68 @@ fn trim_history(convo: &mut Vec<StoredMessage>) {
10562516 }
10572517}
10582518
1059−/// Decode a kv `get` result into a typed value, ignoring errors / absence.
1060−fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
1061− let bytes = res.ok().flatten()?;
1062− serde_json::from_slice(&bytes).ok()
2519+/// Decode a kv `get` result into a typed value.
2520+///
2521+/// Returns `Ok(Some(value))` when the key exists and deserializes, `Ok(None)`
2522+/// when the key is absent (a fresh install), and `Err` when the read itself
2523+/// failed (e.g. the file is locked or corrupt). The caller MUST NOT overwrite
2524+/// a key with a default when the read failed — the data is still on disk, and
2525+/// writing the default back destroys it. An absent key is safe to default.
2526+///
2527+/// The old `parse_kv` conflated read errors with absence, which meant a
2528+/// background wake while the device was locked (where `completeUnlessOpen`
2529+/// refuses to open existing files) would load an empty model, and the next
2530+/// `persist_*` would write that emptiness back over the real data.
2531+fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Result<Option<T>, ()> {
2532+ match res {
2533+ Ok(Some(bytes)) => serde_json::from_slice(&bytes)
2534+ .map(Some)
2535+ .map_err(|_| ()),
2536+ Ok(None) => Ok(None),
2537+ Err(_) => Err(()),
2538+ }
10632539}
10642540
10652541#[cfg(test)]
10662542mod tests {
10672543 use super::*;
10682544 use crate::crypto::Identity;
2545+ use crate::model::{MAX_OUTBOX_RETRIES, OUTBOX_TTL_MS};
10692546
10702547 fn with_identity() -> Model {
1071− let id = Identity::from_seed(&[3u8; 32]);
2548+ let id = Identity::from_seed(&[3u8; 32]).unwrap();
10722549 Model {
1073− secret_key: Some(id.secret_key.to_vec()),
2550+ secret_key: Some(Zeroizing::new(id.secret_key.to_vec())),
10742551 my_pubkey: id.public_key_hex(),
10752552 ..Default::default()
10762553 }
10772554 }
10782555
10792556 fn peer_hex(seed: u8) -> String {
1080− Identity::from_seed(&[seed; 32]).public_key_hex()
2557+ Identity::from_seed(&[seed; 32]).unwrap().public_key_hex()
2558+ }
2559+
2560+ /// `Command::event` *queues* an event; it does not run it. The runtime drains
2561+ /// that queue back into `update`, so a test that stops at the first `update`
2562+ /// only ever sees half of what an event actually does — `Connect` would look
2563+ /// like it never authenticates, and the poll watchdog like it never re-polls.
2564+ /// Drain it the way the runtime does.
2565+ fn drive(app: &Skrepka, cmd: &mut Command<Effect, Event>, model: &mut Model) {
2566+ for event in cmd.events().collect::<Vec<_>>() {
2567+ let mut next = app.update(event, model);
2568+ drive(app, &mut next, model);
2569+ }
2570+ }
2571+
2572+ /// `update` + drain, i.e. what the shell actually observes.
2573+ fn dispatch(app: &Skrepka, event: Event, model: &mut Model) {
2574+ let mut cmd = app.update(event, model);
2575+ drive(app, &mut cmd, model);
10812576 }
10822577
10832578 /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
10842579 fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
1085− let sender = Identity::from_seed(&[sender_seed; 32]);
2580+ let sender = Identity::from_seed(&[sender_seed; 32]).unwrap();
10862581 let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
10872582 let json = protocol::serialize_payload(payload, ts);
10882583 let blob = crate::crypto::encrypt(
@@ -1107,383 +2602,2426 @@ mod tests {
11072602 }
11082603 }
11092604
1110− /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
1111− /// would tell the blocked peer we are online and reading.
2605+ fn queued(recipient: &str, body: &str) -> OutboxItem {
2606+ OutboxItem::new(
2607+ recipient.to_string(),
2608+ Arc::new(protocol::serialize_payload(&text(body, body), 1)),
2609+ )
2610+ }
2611+
2612+ /// The `/messages` batches a command actually puts on the wire. Reading them
2613+ /// off the effect is the only way to see what was *sent*, as opposed to what
2614+ /// the model thinks it sent.
2615+ fn sent_batches(cmd: &mut Command<Effect, Event>) -> Vec<Vec<Envelope>> {
2616+ #[derive(Deserialize)]
2617+ struct Sent {
2618+ messages: Vec<Envelope>,
2619+ }
2620+ cmd.effects()
2621+ .filter_map(|e| match e {
2622+ Effect::Http(req) if req.operation.url.ends_with("/messages") => {
2623+ serde_json::from_slice::<Sent>(&req.operation.body)
2624+ .ok()
2625+ .map(|b| b.messages)
2626+ }
2627+ _ => None,
2628+ })
2629+ .collect()
2630+ }
2631+
2632+ fn ok_response(status: u16) -> crux_http::Response<Vec<u8>> {
2633+ let status = crux_http::http::StatusCode::try_from(status).unwrap();
2634+ crux_http::testing::ResponseBuilder::with_status(status)
2635+ .body(Vec::new())
2636+ .build()
2637+ }
2638+
2639+ /// The same, plus one header — the only thing a relay can say to us beyond a
2640+ /// status code, and `Retry-After` is the one we act on.
2641+ fn response_with_header(status: u16, name: &str, value: &str) -> crux_http::Response<Vec<u8>> {
2642+ let status = crux_http::http::StatusCode::try_from(status).unwrap();
2643+ crux_http::testing::ResponseBuilder::with_status(status)
2644+ .header(name, value)
2645+ .body(Vec::new())
2646+ .build()
2647+ }
2648+
2649+ /// The delays a command actually asks the shell to wait, in milliseconds.
2650+ /// Asserting on the model alone cannot see these — a backoff lives entirely in
2651+ /// the effect.
2652+ fn scheduled_delays(cmd: &mut Command<Effect, Event>) -> Vec<u64> {
2653+ cmd.effects()
2654+ .filter_map(|e| match e {
2655+ Effect::Time(req) => match req.operation {
2656+ crux_time::TimeRequest::NotifyAfter { duration, .. } => {
2657+ Some(Duration::from(duration).as_millis() as u64)
2658+ }
2659+ _ => None,
2660+ },
2661+ _ => None,
2662+ })
2663+ .collect()
2664+ }
2665+
2666+ /// One full send round-trip: flush the outbox, then answer with `status`.
2667+ fn send_and_answer(app: &Skrepka, m: &mut Model, status: u16) {
2668+ let _ = app.update(Event::StartFlush, m);
2669+ let gen = m.flush_gen;
2670+ let _ = app.update(Event::SendResult(gen, Ok(ok_response(status))), m);
2671+ }
2672+
2673+ // -----------------------------------------------------------------------
2674+ // outbox: retry budget and TTL
2675+ // -----------------------------------------------------------------------
2676+
2677+ /// The outbox is a strict FIFO, so an item that can never be delivered used to
2678+ /// block every message queued behind it — forever.
11122679 #[test]
1113− fn a_blocked_senders_message_is_neither_stored_nor_acked() {
2680+ fn an_undeliverable_item_is_dropped_once_it_runs_out_of_retries() {
11142681 let app = Skrepka;
11152682 let mut m = with_identity();
1116− let peer = peer_hex(9);
1117− let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
1118− contact.blocked = true;
1119− m.contacts.insert(peer.clone(), contact);
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"));
11202688
1121− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
1122− let _ = app.ingest_poll(&mut m, page);
2689+ // The relay keeps 500-ing on the head of the queue.
2690+ for i in 1..MAX_OUTBOX_RETRIES {
2691+ send_and_answer(&app, &mut m, 503);
2692+ assert_eq!(m.outbox.len(), 2, "still queued after {i} failure(s)");
2693+ assert_eq!(m.outbox[0].retries, i, "and its budget is being spent");
2694+ }
11232695
1124− assert!(
1125− m.messages.get(&peer).is_none_or(Vec::is_empty),
1126− "a blocked peer's message must not be stored"
2696+ // The last one exhausts the budget: the head goes, and what was stuck
2697+ // behind it goes out.
2698+ let _ = app.update(Event::StartFlush, &mut m);
2699+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(503))), &mut m);
2700+
2701+ assert_eq!(m.outbox.len(), 1, "the undeliverable item is dropped");
2702+ assert_eq!(m.outbox[0].recipient, behind, "and the queue moves on");
2703+ assert_eq!(
2704+ sent_batches(&mut cmd).len(),
2705+ 1,
2706+ "the message behind it is sent immediately, not left for the next flush"
11272707 );
1128− assert!(m.outbox.is_empty(), "no delivery.ack goes back to a blocked peer");
1129− // ...and nothing to render in the chat, even if it is opened.
1130− m.active_peer = Some(peer);
1131− m.page = Page::Chat;
1132− assert!(app.view(&m).messages.is_empty());
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}");
11332710 }
11342711
1135− /// The same page, unblocked: the control that proves the gate is the block
1136− /// flag and not something else in the ingest path.
1137− fn ingest_one_text_from_an_unblocked_peer() -> (Model, String) {
2712+ /// The counter alone is not enough: retries only accrue when a send is
2713+ /// attempted, so a client that goes online once a day would take a fortnight to
2714+ /// spend a ten-retry budget. The TTL bounds it in wall-clock time instead.
2715+ #[test]
2716+ fn an_item_older_than_the_ttl_is_dropped_on_its_next_failure() {
11382717 let app = Skrepka;
11392718 let mut m = with_identity();
2719+ m.token = Some("t".into());
11402720 let peer = peer_hex(9);
1141− let page = page_from(9, &m, &text("m1", "hi"), now_ms());
1142− let _ = app.ingest_poll(&mut m, page);
1143− (m, peer)
2721+
2722+ let mut old = queued(&peer, "ancient");
2723+ old.first_attempt = now_ms() - OUTBOX_TTL_MS - 1;
2724+ old.retries = 1; // nowhere near the retry cap
2725+ m.outbox.push_back(old);
2726+
2727+ send_and_answer(&app, &mut m, 503);
2728+
2729+ assert!(m.outbox.is_empty(), "the TTL alone is enough to drop it");
2730+ assert!(m.error.is_some_and(|e| e.contains("gave up sending")));
11442731 }
11452732
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".
11462737 #[test]
1147− fn an_unblocked_senders_message_is_stored_and_acked() {
1148− let (m, peer) = ingest_one_text_from_an_unblocked_peer();
1149− assert_eq!(m.messages[&peer].len(), 1);
1150− assert_eq!(m.messages[&peer][0].body, "hi");
1151− assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
1152− assert_eq!(m.outbox[0].recipient, peer);
2738+ fn an_item_that_was_never_attempted_has_no_ttl_to_outlive() {
2739+ let item = OutboxItem::new(peer_hex(9), Arc::new("{}".into()));
2740+ assert_eq!(item.first_attempt, 0);
2741+ assert!(
2742+ !item.is_expired(now_ms()),
2743+ "an unsent item must not age out against the epoch"
2744+ );
11532745 }
11542746
1155− /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
1156− /// conversation list forever, and render with an absurd timestamp.
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.
11572749 #[test]
1158− fn a_far_future_timestamp_is_clamped_to_now() {
2750+ fn the_first_attempt_stamp_is_persisted_when_the_item_goes_out() {
11592751 let app = Skrepka;
11602752 let mut m = with_identity();
1161− let peer = peer_hex(9);
1162−
1163− let page = page_from(9, &m, &text("m1", "hi"), i64::MAX);
2753+ m.token = Some("t".into());
2754+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
11642755 let before = now_ms();
1165− let _ = app.ingest_poll(&mut m, page);
11662756
1167− let ts = m.messages[&peer][0].ts;
1168− assert!(
1169− ts <= now_ms() + MAX_FUTURE_SKEW_MS,
1170− "ts {ts} must be clamped into the skew window"
1171− );
1172− assert!(ts >= before, "and clamped to now, not to some past value");
1173− // The clamped value is what the shell sorts and renders by.
1174− assert_eq!(app.view(&m).contacts[0].last_ts, ts);
2757+ let mut cmd = app.update(Event::StartFlush, &mut m);
2758+
2759+ assert!(m.outbox[0].first_attempt >= before, "the item is stamped");
2760+ // ...and the stamp is written to the `outbox` key, not just to memory.
2761+ let wrote_outbox = cmd.effects().any(|e| {
2762+ matches!(e, Effect::KeyValue(req)
2763+ if matches!(&req.operation, crux_kv::KeyValueOperation::Set { key, .. } if key == K_OUTBOX))
2764+ });
2765+ assert!(wrote_outbox, "the stamped outbox is persisted");
11752766 }
11762767
1177− /// Honest clock skew inside the tolerance is preserved as-is.
2768+ /// A transient failure below the budget changes nothing but the counter — the
2769+ /// message stays queued and goes out on the next flush.
11782770 #[test]
1179− fn a_slightly_future_timestamp_is_kept() {
2771+ fn a_transient_failure_within_budget_keeps_the_message() {
11802772 let app = Skrepka;
11812773 let mut m = with_identity();
1182− let peer = peer_hex(9);
1183− let ts = now_ms() + 5_000;
2774+ m.token = Some("t".into());
2775+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
11842776
1185− let page = page_from(9, &m, &text("m1", "hi"), ts);
1186− let _ = app.ingest_poll(&mut m, page);
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");
11872782
1188− assert_eq!(m.messages[&peer][0].ts, ts);
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());
11892786 }
11902787
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.
11912791 #[test]
1192− fn message_history_is_capped_at_the_cutoff() {
2792+ fn a_401_does_not_spend_the_retry_budget() {
11932793 let app = Skrepka;
11942794 let mut m = with_identity();
1195− let peer = peer_hex(9);
1196−
1197− let convo = m.messages.entry(peer.clone()).or_default();
1198− for i in 0..MAX_MESSAGES_PER_PEER {
1199− convo.push(StoredMessage {
1200− id: format!("old-{i}"),
1201− body: "x".into(),
1202− ts: i as i64,
1203− outgoing: false,
1204− delivered: false,
1205− });
1206− }
2795+ m.token = Some("t".into());
2796+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
12072797
1208− let page = page_from(9, &m, &text("new", "newest"), now_ms());
1209− let _ = app.ingest_poll(&mut m, page);
2798+ send_and_answer(&app, &mut m, 401);
12102799
1211− let convo = &m.messages[&peer];
1212− assert_eq!(convo.len(), MAX_MESSAGES_PER_PEER, "the cap holds");
1213− assert_eq!(convo.last().unwrap().id, "new", "the newest is kept");
1214− assert_eq!(convo.first().unwrap().id, "old-1", "the oldest is aged out");
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");
12152803 }
12162804
1217− /// An off-curve key can't be encrypted to, so the send is dropped — but the
1218− /// user has to be told, rather than watching the message vanish.
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.
12192809 #[test]
1220− fn a_send_to_an_unusable_key_reports_an_error() {
2810+ fn a_429_waits_out_retry_after_without_spending_the_retry_budget() {
12212811 let app = Skrepka;
12222812 let mut m = with_identity();
12232813 m.token = Some("t".into());
1224− let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
1225− m.outbox.push_back(OutboxItem {
1226− recipient: bad,
1227− envelope_json: "{}".into(),
1228− });
2814+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
12292815
12302816 let _ = app.update(Event::StartFlush, &mut m);
2817+ let mut cmd = app.update(
2818+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
2819+ &mut m,
2820+ );
12312821
1232− assert!(m.outbox.is_empty(), "the unsendable item does not wedge the outbox");
1233− assert!(!m.flushing);
1234− let err = m.error.expect("the failure is surfaced");
1235− assert!(err.contains("invalid key"), "got: {err}");
2822+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2823+ assert_eq!(m.outbox[0].retries, 0, "and unpunished — it did nothing wrong");
2824+ assert!(!m.flushing, "the guard is released so the retry can run");
2825+ assert_eq!(
2826+ scheduled_delays(&mut cmd),
2827+ vec![2_000],
2828+ "and we wait exactly as long as the relay asked"
2829+ );
12362830 }
12372831
2832+ /// Without the header there is nothing to honour, so fall back to our own
2833+ /// backoff rather than hammering the relay that just asked us to slow down.
2834+ ///
2835+ /// On the *send* loop's counter. `poll_retries` is reset by every page the poll
2836+ /// loop takes, so spending it here means a client that polls happily while the
2837+ /// relay throttles its sends restarts the send backoff at 3 s forever.
12382838 #[test]
1239− fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
2839+ fn a_429_without_header_uses_send_side_backoff() {
12402840 let app = Skrepka;
12412841 let mut m = with_identity();
1242− let bad = hex::encode([0xabu8; 32]);
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;
12432847
1244− let _ = app.update(
1245− Event::AddContact {
1246− input: bad.clone(),
1247− nickname: "Mallory".into(),
1248− },
1249− &mut m,
2848+ let _ = app.update(Event::StartFlush, &mut m);
2849+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(429))), &mut m);
2850+
2851+ assert_eq!(m.outbox[0].retries, 0, "still not the message's fault");
2852+ assert_eq!(
2853+ scheduled_delays(&mut cmd),
2854+ vec![backoff_ms(0)],
2855+ "the first send-side backoff"
12502856 );
2857+ assert_eq!(m.send_retries, 1, "and the send loop's own counter is spent");
12512858
1252− assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
1253− assert!(m.error.is_some());
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);
12542865 }
12552866
1256− /// A dropped kv write silently loses data; it must not be swallowed.
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.
12572871 #[test]
1258− fn a_failed_save_surfaces_an_error() {
2872+ fn a_429_pauses_flush_until_the_delay_elapses() {
12592873 let app = Skrepka;
12602874 let mut m = with_identity();
2875+ m.token = Some("t".into());
2876+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
12612877
2878+ let _ = app.update(Event::StartFlush, &mut m);
12622879 let _ = app.update(
1263− Event::Saved(Err(crux_kv::error::KeyValueError::Io {
1264− message: "no space left on device".into(),
1265− })),
2880+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "2"))),
12662881 &mut m,
12672882 );
1268− assert!(m.error.is_some(), "a failed write is reported");
2883+ assert!(
2884+ m.flush_paused_until > now_ms(),
2885+ "the relay's wait is latched on the model, not just on a timer"
2886+ );
12692887
1270− // A successful write stays quiet.
1271− m.error = None;
1272− let _ = app.update(Event::Saved(Ok(None)), &mut m);
1273− assert!(m.error.is_none());
2888+ // Anything else that wakes the send loop during the wait — a poll page, a new
2889+ // message — must find it closed.
2890+ let mut cmd = app.update(Event::StartFlush, &mut m);
2891+ assert!(
2892+ sent_batches(&mut cmd).is_empty(),
2893+ "nothing goes out while the relay has asked us to wait"
2894+ );
2895+ assert!(!m.flushing, "and the loop is left free to resume later");
2896+ assert_eq!(m.outbox.len(), 1, "the message is still queued");
2897+
2898+ // Time passes (the clock cannot be moved in a test, so retire the pause the
2899+ // way the elapsing of it would) and the timer's own `StartFlush` lands.
2900+ m.flush_paused_until = 0;
2901+ let mut cmd = app.update(Event::StartFlush, &mut m);
2902+ assert_eq!(
2903+ sent_batches(&mut cmd).len(),
2904+ 1,
2905+ "and once the wait is over the batch goes out"
2906+ );
12742907 }
12752908
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.
12762912 #[test]
1277− fn server_host_strips_scheme_port_and_case() {
1278− assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
1279− assert_eq!(server_host("http://localhost:8080"), "localhost");
1280− assert_eq!(server_host("relay.example.com."), "relay.example.com");
2913+ fn a_successful_send_clears_the_send_side_backoff() {
2914+ let app = Skrepka;
2915+ let mut m = with_identity();
2916+ m.token = Some("t".into());
2917+ m.send_retries = 4;
2918+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2919+
2920+ send_and_answer(&app, &mut m, 200);
2921+
2922+ assert_eq!(m.send_retries, 0, "the relay is taking our sends again");
12812923 }
12822924
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.
12832927 #[test]
1284− fn backoff_is_capped() {
1285− assert_eq!(backoff_ms(0), 3000);
1286− assert_eq!(backoff_ms(1), 6000);
1287− assert_eq!(backoff_ms(3), 24000);
1288− assert_eq!(backoff_ms(10), 30000);
1289− }
1290−
1291− #[test]
1292− fn add_contact_accepts_hex_and_rejects_self() {
2928+ fn an_absurd_retry_after_is_clamped() {
12932929 let app = Skrepka;
12942930 let mut m = with_identity();
1295− let peer = peer_hex(9);
1296− let _ = app.update(
1297− Event::AddContact {
1298− input: peer.clone(),
1299− nickname: "Bob".into(),
1300− },
2931+ m.token = Some("t".into());
2932+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
2933+
2934+ let _ = app.update(Event::StartFlush, &mut m);
2935+ let mut cmd = app.update(
2936+ Event::SendResult(m.flush_gen, Ok(response_with_header(429, "retry-after", "999999999"))),
13012937 &mut m,
13022938 );
1303− assert!(m.contacts.contains_key(&peer));
1304− assert_eq!(m.contacts[&peer].nickname, "Bob");
13052939
1306− // adding own key is rejected with an error
1307− let mine = m.my_pubkey.clone();
1308− let _ = app.update(
1309− Event::AddContact {
1310− input: mine,
1311− nickname: String::new(),
1312− },
1313− &mut m,
2940+ assert_eq!(
2941+ scheduled_delays(&mut cmd),
2942+ vec![MAX_RETRY_AFTER_MS],
2943+ "we wait, but only as long as we are willing to"
13142944 );
1315− assert!(m.error.is_some());
13162945 }
13172946
2947+ // -----------------------------------------------------------------------
2948+ // outbox: batching
2949+ // -----------------------------------------------------------------------
2950+
2951+ /// Sending one message per request meant a round-trip per *ack* too, so
2952+ /// catching up on a hundred messages cost a hundred requests.
13182953 #[test]
1319− fn send_text_enqueues_message_profile_and_outbox() {
2954+ fn consecutive_items_for_one_recipient_go_out_in_a_single_request() {
13202955 let app = Skrepka;
13212956 let mut m = with_identity();
2957+ m.token = Some("t".into());
13222958 let peer = peer_hex(9);
1323− m.active_peer = Some(peer.clone());
1324− m.compose = "hello".into();
2959+ for i in 0..5 {
2960+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
2961+ }
13252962
1326− let _ = app.update(Event::SendText, &mut m);
2963+ let mut cmd = app.update(Event::StartFlush, &mut m);
13272964
1328− // one outgoing message recorded
1329− let convo = &m.messages[&peer];
1330− assert_eq!(convo.len(), 1);
1331− assert!(convo[0].outgoing);
1332− assert_eq!(convo[0].body, "hello");
1333− // compose cleared, contact auto-created
1334− assert!(m.compose.is_empty());
1335− assert!(m.contacts.contains_key(&peer));
1336− // outbox has the text + a first-contact profile
2965+ let batches = sent_batches(&mut cmd);
2966+ assert_eq!(batches.len(), 1, "one request, not five");
2967+ assert_eq!(batches[0].len(), 5, "carrying every message");
2968+ assert!(batches[0].iter().all(|e| e.to == peer));
2969+
2970+ // ...and its success clears the whole batch, not just the head.
2971+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
2972+ assert!(m.outbox.is_empty());
2973+ }
2974+
2975+ /// The batch stops at the first recipient change: a batch is delivered as a
2976+ /// unit, and hoisting a later item for the same peer over an earlier one for a
2977+ /// different peer would let one busy conversation starve the rest of the queue.
2978+ #[test]
2979+ fn a_batch_stops_at_the_first_recipient_change() {
2980+ let app = Skrepka;
2981+ let mut m = with_identity();
2982+ m.token = Some("t".into());
2983+ let a = peer_hex(9);
2984+ let b = peer_hex(11);
2985+ m.outbox.push_back(queued(&a, "a1"));
2986+ m.outbox.push_back(queued(&a, "a2"));
2987+ m.outbox.push_back(queued(&b, "b1"));
2988+ m.outbox.push_back(queued(&a, "a3"));
2989+
2990+ let mut cmd = app.update(Event::StartFlush, &mut m);
2991+ let batches = sent_batches(&mut cmd);
2992+ assert_eq!(batches.len(), 1);
2993+ assert_eq!(batches[0].len(), 2, "only the run at the head");
2994+ assert!(batches[0].iter().all(|e| e.to == a));
2995+
2996+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
13372997 assert_eq!(m.outbox.len(), 2);
1338− assert_eq!(m.outbox[0].recipient, peer);
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);
13393001 }
13403002
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.
13413005 #[test]
1342− fn send_text_without_peer_or_empty_is_noop() {
3006+ fn a_batch_is_capped_at_the_relays_limit() {
13433007 let app = Skrepka;
13443008 let mut m = with_identity();
1345− m.compose = "x".into();
1346− let _ = app.update(Event::SendText, &mut m); // no active peer
1347− assert!(m.outbox.is_empty());
3009+ m.token = Some("t".into());
3010+ let peer = peer_hex(9);
3011+ for i in 0..(MAX_SEND_BATCH + 20) {
3012+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
3013+ }
13483014
1349− m.active_peer = Some(peer_hex(9));
1350− m.compose = " ".into();
1351− let _ = app.update(Event::SendText, &mut m); // whitespace only
1352− assert!(m.outbox.is_empty());
3015+ let mut cmd = app.update(Event::StartFlush, &mut m);
3016+ let batches = sent_batches(&mut cmd);
3017+ assert_eq!(batches[0].len(), MAX_SEND_BATCH);
3018+
3019+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3020+ assert_eq!(m.outbox.len(), 20, "the rest stays queued for the next batch");
13533021 }
13543022
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.
13553026 #[test]
1356− fn set_blocked_toggles_and_view_reflects_it() {
3027+ fn a_batch_is_capped_by_body_size_as_well_as_count() {
13573028 let app = Skrepka;
13583029 let mut m = with_identity();
3030+ m.token = Some("t".into());
13593031 let peer = peer_hex(9);
1360− m.contacts
1361− .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
1362− let _ = app.update(
1363− Event::SetBlocked {
1364− peer: peer.clone(),
1365− blocked: true,
1366− },
1367− &mut m,
1368− );
1369− assert!(m.contacts[&peer].blocked);
1370− let vm = app.view(&m);
1371− assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
3032+ // A body at the cap, and incompressible — `crypto::encrypt` deflates the
3033+ // plaintext, so a run of one character would ride out to nearly nothing and
3034+ // prove no cap at all.
3035+ let mut seed = 1u64;
3036+ let big: String = std::iter::repeat_with(|| {
3037+ seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3038+ char::from(b'a' + (seed >> 33) as u8 % 26)
3039+ })
3040+ .take(protocol::MAX_BODY_LEN)
3041+ .collect();
3042+ for i in 0..MAX_SEND_BATCH {
3043+ m.outbox.push_back(OutboxItem::new(
3044+ peer.clone(),
3045+ Arc::new(protocol::serialize_payload(&text(&format!("m{i}"), &big), 1)),
3046+ ));
3047+ }
3048+
3049+ let mut cmd = app.update(Event::StartFlush, &mut m);
3050+ let batches = sent_batches(&mut cmd);
3051+ let body: usize = batches[0].iter().map(|e| e.encrypted_blob.len()).sum();
3052+
3053+ assert!(batches[0].len() < MAX_SEND_BATCH, "the count cap is not the binding one");
3054+ assert!(!batches[0].is_empty(), "but something still goes out");
3055+ assert!(body <= MAX_SEND_BATCH_BYTES, "the body stays inside the budget");
13723056 }
13733057
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.
13743063 #[test]
1375− fn normalize_server_url_requires_an_http_scheme() {
3064+ fn a_413_halves_the_batch_budget_and_retries_smaller() {
3065+ let app = Skrepka;
3066+ let mut m = with_identity();
3067+ m.token = Some("t".into());
3068+ let peer = peer_hex(9);
3069+ for i in 0..5 {
3070+ m.outbox.push_back(queued(&peer, &format!("m{i}")));
3071+ }
3072+
3073+ // Nothing is known about this relay yet, so all five go out at once.
3074+ let mut cmd = app.update(Event::StartFlush, &mut m);
3075+ let first = sent_batches(&mut cmd);
3076+ assert_eq!(first[0].len(), 5, "the whole run goes out on the first attempt");
3077+ assert!(m.send_batch_budget.is_none(), "and we have assumed nothing");
3078+ let sent_bytes: usize = first[0].iter().map(|e| e.encrypted_blob.len()).sum();
3079+
3080+ // ...and the relay says that was too big.
3081+ let mut cmd = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(413))), &mut m);
3082+
13763083 assert_eq!(
1377− normalize_server_url(" https://Relay.Example.com/ "),
1378− Some("https://Relay.Example.com".to_string())
3084+ m.send_batch_budget,
3085+ Some(sent_bytes / 2),
3086+ "the budget is halved against what actually failed, not against the constant"
3087+ );
3088+ assert_eq!(m.outbox.len(), 5, "a 413 delivered nothing, so nothing is popped");
3089+ assert!(
3090+ m.outbox.iter().all(|i| i.retries == 0),
3091+ "and the items are not charged for the relay being smaller than we guessed"
13793092 );
3093+
3094+ // The retry goes out at once, and it is smaller.
3095+ let second = sent_batches(&mut cmd);
3096+ assert_eq!(second.len(), 1, "retried immediately, not left for the next flush");
3097+ let shrunk = second[0].len();
3098+ assert!(shrunk < 5, "with a smaller batch, got {shrunk} items");
3099+ assert!(shrunk > 0, "but not an empty one");
3100+
3101+ // The relay takes the smaller batch, and exactly it is popped.
3102+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3103+ assert_eq!(m.outbox.len(), 5 - shrunk, "exactly the batch that landed is gone");
13803104 assert_eq!(
1381− normalize_server_url("HTTP://localhost:8080///"),
1382− Some("http://localhost:8080".to_string())
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"
13833108 );
1384− // The cases that used to panic Http::post or produce a dead URL.
1385− assert_eq!(normalize_server_url("relay.example.com"), None);
1386− assert_eq!(normalize_server_url("localhost:8080"), None);
1387− assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
1388− assert_eq!(normalize_server_url("http://"), None);
1389− assert_eq!(normalize_server_url("http://:8080"), None);
1390− assert_eq!(normalize_server_url("https://relay example.com"), None);
1391− assert_eq!(normalize_server_url(""), None);
13923109 }
13933110
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.
13943115 #[test]
1395− fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
3116+ fn a_413_on_a_single_item_ages_out_instead_of_spinning() {
13963117 let app = Skrepka;
13973118 let mut m = with_identity();
1398− let original = m.settings.server_url.clone();
3119+ m.token = Some("t".into());
3120+ m.outbox.push_back(queued(&peer_hex(9), "bigger than this relay allows"));
13993121
1400− let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
1401− assert_eq!(m.settings.server_url, original, "bad URL must not persist");
1402− assert!(m.error.is_some());
3122+ for i in 1..MAX_OUTBOX_RETRIES {
3123+ send_and_answer(&app, &mut m, 413);
3124+ assert_eq!(m.outbox.len(), 1, "still queued after {i} rejection(s)");
3125+ assert_eq!(m.outbox[0].retries, i, "and charged for each of them");
3126+ }
3127+ assert!(
3128+ m.send_batch_budget.is_none(),
3129+ "an unsplittable batch teaches us nothing about the cap"
3130+ );
14033131
1404− let _ = app.update(
1405− Event::SetServerUrl(" https://relay.example.com/ ".into()),
1406− &mut m,
3132+ send_and_answer(&app, &mut m, 413);
3133+ assert!(m.outbox.is_empty(), "the item that can never be sent is dropped");
3134+ assert!(
3135+ m.error.is_some_and(|e| e.contains("gave up sending")),
3136+ "and the user is told, rather than the message quietly vanishing"
14073137 );
1408− assert_eq!(m.settings.server_url, "https://relay.example.com");
1409− assert!(m.error.is_none());
1410− assert!(m.token.is_none());
1411− assert_eq!(m.conn, ConnStatus::Offline);
14123138 }
14133139
1414− /// The regression that stalled the outbox: `flushing` stayed `true` forever
1415− /// after the first successful send, so nothing was ever sent again.
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.
14163142 #[test]
1417− fn send_result_ok_clears_flushing() {
3143+ fn changing_relay_forgets_the_learned_batch_budget() {
14183144 let app = Skrepka;
14193145 let mut m = with_identity();
1420− m.token = Some("t".into());
1421− m.flushing = true;
1422− m.outbox.push_back(OutboxItem {
1423− recipient: peer_hex(9),
1424− envelope_json: "{}".into(),
1425− });
3146+ m.send_batch_budget = Some(1024);
14263147
1427− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1428− let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
3148+ let _ = app.update(Event::SetServerUrl("https://other.example".into()), &mut m);
14293149
1430− assert!(!m.flushing, "flushing must be cleared on the success path");
1431− assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
3150+ assert!(m.send_batch_budget.is_none());
14323151 }
14333152
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.
14343155 #[test]
1435− fn flush_next_resumes_after_a_successful_send() {
3156+ fn an_unsendable_head_is_dropped_and_the_batch_behind_it_still_goes_out() {
14363157 let app = Skrepka;
14373158 let mut m = with_identity();
14383159 m.token = Some("t".into());
1439− for _ in 0..2 {
1440− m.outbox.push_back(OutboxItem {
1441− recipient: peer_hex(9),
1442− envelope_json: "{}".into(),
1443− });
3160+ let good = peer_hex(9);
3161+ m.outbox.push_back(queued(&hex::encode([0xabu8; 32]), "doomed"));
3162+ m.outbox.push_back(queued(&good, "fine"));
3163+ m.outbox.push_back(queued(&good, "also fine"));
3164+
3165+ let mut cmd = app.update(Event::StartFlush, &mut m);
3166+
3167+ assert_eq!(m.outbox.len(), 2, "only the unsendable item is dropped");
3168+ let batches = sent_batches(&mut cmd);
3169+ assert_eq!(batches[0].len(), 2, "and the batch behind it goes out whole");
3170+ assert!(m.error.is_some_and(|e| e.contains("invalid key")));
3171+ }
3172+
3173+ // -----------------------------------------------------------------------
3174+ // outbox: profile supersede
3175+ // -----------------------------------------------------------------------
3176+
3177+ /// A profile is state, not an event: only the newest one means anything. Five
3178+ /// quick edits used to queue five payloads per contact — each with the photo.
3179+ #[test]
3180+ fn repeated_profile_edits_supersede_rather_than_pile_up() {
3181+ let app = Skrepka;
3182+ let mut m = with_identity();
3183+ let a = peer_hex(9);
3184+ let b = peer_hex(11);
3185+ for peer in [&a, &b] {
3186+ m.contacts
3187+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
14443188 }
1445− // First send goes out and marks the model as flushing.
3189+
3190+ for name in ["One", "Two", "Three"] {
3191+ let _ = app.update(
3192+ Event::SaveProfile {
3193+ display_name: name.into(),
3194+ bio: String::new(),
3195+ photo: None,
3196+ },
3197+ &mut m,
3198+ );
3199+ }
3200+
3201+ assert_eq!(m.outbox.len(), 2, "one pending profile per contact, not three");
3202+ for item in &m.outbox {
3203+ assert!(item.is_profile());
3204+ let parsed = protocol::parse_payload(item.envelope_json.as_bytes()).unwrap();
3205+ match parsed.payload {
3206+ Payload::Profile { display_name, .. } => {
3207+ assert_eq!(display_name, Some("Three".to_string()), "and it is the latest one");
3208+ }
3209+ _ => panic!("expected a profile"),
3210+ }
3211+ }
3212+ }
3213+
3214+ /// Superseding must not reach into a payload that is already on the wire: its
3215+ /// ciphertext is built and gone, and `SendResult` pops it on success — so a
3216+ /// profile swapped in now would be dropped having never been sent.
3217+ #[test]
3218+ fn a_profile_already_in_flight_is_not_rewritten_under_the_send() {
3219+ let app = Skrepka;
3220+ let mut m = with_identity();
3221+ m.token = Some("t".into());
3222+ let peer = peer_hex(9);
3223+ m.contacts
3224+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3225+
3226+ let _ = app.update(
3227+ Event::SaveProfile {
3228+ display_name: "Old".into(),
3229+ bio: String::new(),
3230+ photo: None,
3231+ },
3232+ &mut m,
3233+ );
3234+ // It goes out and is still in flight.
14463235 let _ = app.update(Event::StartFlush, &mut m);
14473236 assert!(m.flushing);
14483237
1449− // Its success must both drop the item and let the *next* one go out.
1450− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1451− let _ = app.update(Event::SendResult(Ok(resp)), &mut m);
1452− assert_eq!(m.outbox.len(), 1);
1453− assert!(m.flushing, "the second item is now in flight");
3238+ // A second edit lands mid-send. It must queue a *new* item rather than
3239+ // rewrite the one whose ciphertext is already gone.
3240+ let _ = app.update(
3241+ Event::SaveProfile {
3242+ display_name: "New".into(),
3243+ bio: String::new(),
3244+ photo: None,
3245+ },
3246+ &mut m,
3247+ );
3248+ assert_eq!(m.outbox.len(), 2, "the in-flight item is left alone");
3249+
3250+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3251+ assert_eq!(m.outbox.len(), 1, "the sent one is popped");
3252+ let parsed = protocol::parse_payload(m.outbox[0].envelope_json.as_bytes()).unwrap();
3253+ match parsed.payload {
3254+ Payload::Profile { display_name, .. } => assert_eq!(
3255+ display_name, Some("New".to_string()),
3256+ "and the newer profile survives to be sent"
3257+ ),
3258+ _ => panic!("expected a profile"),
3259+ }
14543260 }
14553261
3262+ // -----------------------------------------------------------------------
3263+ // in-flight bookkeeping
3264+ // -----------------------------------------------------------------------
3265+
3266+ /// `SendResult` pops by count off the head of the outbox. If the items it sent
3267+ /// were removed from under it — the user blocked the peer mid-send — popping by
3268+ /// count anyway takes whatever slid into their place: another peer's mail.
14563269 #[test]
1457− fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
3270+ fn blocking_the_peer_mid_send_does_not_pop_someone_elses_messages() {
14583271 let app = Skrepka;
14593272 let mut m = with_identity();
14603273 m.token = Some("t".into());
3274+ let enemy = peer_hex(9);
3275+ let friend = peer_hex(11);
3276+ m.contacts
3277+ .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3278+ m.outbox.push_back(queued(&enemy, "e1"));
3279+ m.outbox.push_back(queued(&enemy, "e2"));
3280+ m.outbox.push_back(queued(&friend, "f1"));
14613281
1462− let _ = app.update(Event::Poll, &mut m);
1463− assert!(m.polling, "the first Poll issues a request");
3282+ let _ = app.update(Event::StartFlush, &mut m);
3283+ assert_eq!(m.in_flight.as_ref().unwrap().count, 2);
14643284
1465− // A second Poll (e.g. from a re-auth) must not stack another loop.
1466− let _ = app.update(Event::Poll, &mut m);
1467− assert!(m.polling);
3285+ // Blocked mid-flight: the two items in the air are dropped from the queue.
3286+ let _ = app.update(
3287+ Event::SetBlocked {
3288+ peer: enemy.clone(),
3289+ blocked: true,
3290+ },
3291+ &mut m,
3292+ );
3293+ assert_eq!(m.outbox.len(), 1);
14683294
1469− // Both success and failure release the guard so the loop can continue.
1470− let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
1471− let _ = app.update(Event::PollResult(Ok(resp)), &mut m);
1472− assert!(!m.polling);
3295+ // The send lands. It must pop nothing.
3296+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
3297+ assert_eq!(m.outbox.len(), 1, "the friend's message is untouched");
3298+ assert_eq!(m.outbox[0].recipient, friend);
3299+ }
14733300
1474− let _ = app.update(Event::Poll, &mut m);
1475− assert!(m.polling);
1476− let _ = app.update(Event::PollResult(Err(crux_http::HttpError::Timeout)), &mut m);
1477− assert!(!m.polling);
3301+ // -----------------------------------------------------------------------
3302+ // delete contact
3303+ // -----------------------------------------------------------------------
3304+
3305+ /// Blocking only silences a peer; the entry stayed in the list forever.
3306+ #[test]
3307+ fn deleting_a_contact_forgets_the_contact_the_history_and_the_queue() {
3308+ let app = Skrepka;
3309+ let mut m = with_identity();
3310+ let peer = peer_hex(9);
3311+ let other = peer_hex(11);
3312+ for p in [&peer, &other] {
3313+ m.contacts
3314+ .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3315+ m.messages.insert(
3316+ p.clone(),
3317+ vec![StoredMessage {
3318+ id: "m1".into(),
3319+ body: "hi".into(),
3320+ ts: 1,
3321+ outgoing: false,
3322+ delivered: false,
3323+ }],
3324+ );
3325+ m.outbox.push_back(queued(p, "queued"));
3326+ }
3327+ m.active_peer = Some(peer.clone());
3328+ m.page = Page::Chat;
3329+
3330+ let mut cmd = app.update(Event::DeleteContact { peer: peer.clone() }, &mut m);
3331+
3332+ assert!(!m.contacts.contains_key(&peer));
3333+ assert!(!m.messages.contains_key(&peer));
3334+ assert!(
3335+ m.outbox.iter().all(|i| i.recipient != peer),
3336+ "nothing more goes to a peer we have forgotten"
3337+ );
3338+ assert_eq!(m.page, Page::Conversations, "the open chat is closed");
3339+ assert!(m.active_peer.is_none());
3340+
3341+ // Everyone else is untouched.
3342+ assert!(m.contacts.contains_key(&other));
3343+ assert_eq!(m.messages[&other].len(), 1);
3344+ assert_eq!(m.outbox.len(), 1);
3345+
3346+ // The history blob is *deleted*, not left behind holding what the user just
3347+ // asked us to forget.
3348+ let deleted = cmd.effects().any(|e| {
3349+ matches!(e, Effect::KeyValue(req)
3350+ if matches!(&req.operation, crux_kv::KeyValueOperation::Delete { key } if key == &k_messages(&peer)))
3351+ });
3352+ assert!(deleted, "the messages:<peer> key is removed");
14783353 }
14793354
3355+ /// Deleting a peer we are not looking at must not throw the user out of the
3356+ /// chat they *are* looking at.
14803357 #[test]
1481− fn view_exposes_identity_and_ob() {
3358+ fn deleting_another_contact_leaves_the_open_chat_alone() {
14823359 let app = Skrepka;
1483− let m = with_identity();
1484− let vm = app.view(&m);
1485− assert!(vm.has_identity);
1486− assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
1487− assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
3360+ let mut m = with_identity();
3361+ let open = peer_hex(9);
3362+ let doomed = peer_hex(11);
3363+ for p in [&open, &doomed] {
3364+ m.contacts
3365+ .insert(p.clone(), Contact::new(p.clone(), String::new(), 0));
3366+ }
3367+ m.active_peer = Some(open.clone());
3368+ m.page = Page::Chat;
3369+
3370+ let _ = app.update(Event::DeleteContact { peer: doomed }, &mut m);
3371+
3372+ assert_eq!(m.page, Page::Chat);
3373+ assert_eq!(m.active_peer.as_deref(), Some(open.as_str()));
3374+ }
3375+
3376+ // -----------------------------------------------------------------------
3377+ // watermark clamping
3378+ // -----------------------------------------------------------------------
3379+
3380+ /// An ack at `ts = i64::MAX` used to park `last_ack_ts` a minute into the
3381+ /// future — and every honest ack that followed, the ones that actually mark our
3382+ /// messages delivered, read as stale and was dropped. A peer could silence its
3383+ /// own delivery receipts forever with one payload.
3384+ #[test]
3385+ fn a_far_future_ack_cannot_suppress_the_acks_that_follow_it() {
3386+ let app = Skrepka;
3387+ let mut m = with_identity();
3388+ let peer = peer_hex(9);
3389+ m.contacts
3390+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3391+ m.messages.insert(
3392+ peer.clone(),
3393+ vec![StoredMessage {
3394+ id: "m1".into(),
3395+ body: "hi".into(),
3396+ ts: 1,
3397+ outgoing: true,
3398+ delivered: false,
3399+ }],
3400+ );
3401+
3402+ // The poisoned ack: acks nothing we sent, but sets the watermark.
3403+ let ack = Payload::DeliveryAck {
3404+ ack_ids: vec!["nothing".into()],
3405+ };
3406+ let page = page_from(9, &m, &ack, i64::MAX);
3407+ let _ = app.ingest_poll(&mut m, page);
3408+ assert!(
3409+ m.contacts[&peer].last_ack_ts <= now_ms(),
3410+ "the watermark cannot be pushed into the future"
3411+ );
3412+
3413+ // A perfectly ordinary ack, sent now. It must still land.
3414+ let ack = Payload::DeliveryAck {
3415+ ack_ids: vec!["m1".into()],
3416+ };
3417+ let page = page_from(9, &m, &ack, now_ms());
3418+ let _ = app.ingest_poll(&mut m, page);
3419+ assert!(
3420+ m.messages[&peer][0].delivered,
3421+ "an honest ack after a far-future one must still be honoured"
3422+ );
3423+ }
3424+
3425+ /// The same trick against `last_profile_ts`: a profile at `ts = i64::MAX` would
3426+ /// freeze the peer's own entry, since every honest update they sent afterwards
3427+ /// would read as a stale replay of itself.
3428+ #[test]
3429+ fn a_far_future_profile_cannot_freeze_the_peers_own_profile() {
3430+ let app = Skrepka;
3431+ let mut m = with_identity();
3432+ let peer = peer_hex(9);
3433+ m.contacts
3434+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
3435+
3436+ let profile = |name: &str| Payload::Profile {
3437+ display_name: Some(name.to_string()),
3438+ bio: Some(String::new()),
3439+ photo: None,
3440+ };
3441+ let page = page_from(9, &m, &profile("Far"), i64::MAX);
3442+ let _ = app.ingest_poll(&mut m, page);
3443+ let first_ts = m.contacts[&peer].last_profile_ts;
3444+ assert!(first_ts <= now_ms());
3445+
3446+ // A later profile at a greater ts must still apply — the clamping
3447+ // is what stops the far-future one from freezing the peer.
3448+ let page = page_from(9, &m, &profile("Now"), first_ts + 1);
3449+ let _ = app.ingest_poll(&mut m, page);
3450+ assert_eq!(
3451+ m.contacts[&peer].display_name, "Now",
3452+ "a later, honest profile must still apply"
3453+ );
3454+ }
3455+
3456+ // -----------------------------------------------------------------------
3457+ // conversation ordering
3458+ // -----------------------------------------------------------------------
3459+
3460+ /// The conversation is held in `ts` order rather than re-sorted per arrival.
3461+ /// `trim_history` ages out the *front*, so an out-of-order insert does not just
3462+ /// render wrong — it throws away the wrong message.
3463+ #[test]
3464+ fn messages_are_inserted_in_timestamp_order() {
3465+ let app = Skrepka;
3466+ let mut m = with_identity();
3467+ let peer = peer_hex(9);
3468+
3469+ // Arrive out of order, including a tie.
3470+ for (i, ts) in [300i64, 100, 200, 200].into_iter().enumerate() {
3471+ let page = page_from(9, &m, &text(&format!("m{i}"), "x"), ts);
3472+ let _ = app.ingest_poll(&mut m, page);
3473+ }
3474+ // ...and an outgoing message, which used to be appended blindly.
3475+ m.active_peer = Some(peer.clone());
3476+ m.compose = "mine".into();
3477+ let _ = app.update(Event::SendText, &mut m);
3478+
3479+ let convo = &m.messages[&peer];
3480+ assert!(
3481+ convo.windows(2).all(|w| w[0].ts <= w[1].ts),
3482+ "the conversation is sorted: {:?}",
3483+ convo.iter().map(|s| s.ts).collect::<Vec<_>>()
3484+ );
3485+ // Ties keep arrival order, exactly as the stable sort used to give.
3486+ let tied: Vec<&str> = convo
3487+ .iter()
3488+ .filter(|s| s.ts == 200)
3489+ .map(|s| s.id.as_str())
3490+ .collect();
3491+ assert_eq!(tied, vec!["m2", "m3"]);
3492+ }
3493+
3494+ /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
3495+ /// would tell the blocked peer we are online and reading.
3496+ #[test]
3497+ fn a_blocked_senders_message_is_neither_stored_nor_acked() {
3498+ let app = Skrepka;
3499+ let mut m = with_identity();
3500+ let peer = peer_hex(9);
3501+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3502+ contact.blocked = true;
3503+ m.contacts.insert(peer.clone(), contact);
3504+
3505+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3506+ let _ = app.ingest_poll(&mut m, page);
3507+
3508+ assert!(
3509+ m.messages.get(&peer).is_none_or(Vec::is_empty),
3510+ "a blocked peer's message must not be stored"
3511+ );
3512+ assert!(m.outbox.is_empty(), "no delivery.ack goes back to a blocked peer");
3513+ // ...and nothing to render in the chat, even if it is opened.
3514+ m.active_peer = Some(peer);
3515+ m.page = Page::Chat;
3516+ assert!(app.view(&m).messages.is_empty());
3517+ }
3518+
3519+ /// The same page, unblocked: the control that proves the gate is the block
3520+ /// flag and not something else in the ingest path.
3521+ fn ingest_one_text_from_an_unblocked_peer() -> (Model, String) {
3522+ let app = Skrepka;
3523+ let mut m = with_identity();
3524+ let peer = peer_hex(9);
3525+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
3526+ let _ = app.ingest_poll(&mut m, page);
3527+ (m, peer)
3528+ }
3529+
3530+ #[test]
3531+ fn an_unblocked_senders_message_is_stored_and_acked() {
3532+ let (m, peer) = ingest_one_text_from_an_unblocked_peer();
3533+ assert_eq!(m.messages[&peer].len(), 1);
3534+ assert_eq!(m.messages[&peer][0].body, "hi");
3535+ // D6-app: A profile broadcast is queued alongside the delivery.ack
3536+ // so the new contact sees our display name and avatar.
3537+ assert_eq!(m.outbox.len(), 2, "one delivery.ack + one profile queued");
3538+ assert_eq!(m.outbox[0].recipient, peer);
3539+ assert_eq!(m.outbox[1].recipient, peer);
3540+ }
3541+
3542+ /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
3543+ /// conversation list forever, and render with an absurd timestamp.
3544+ #[test]
3545+ fn a_far_future_timestamp_is_clamped_to_now() {
3546+ let app = Skrepka;
3547+ let mut m = with_identity();
3548+ let peer = peer_hex(9);
3549+
3550+ let page = page_from(9, &m, &text("m1", "hi"), i64::MAX);
3551+ let before = now_ms();
3552+ let _ = app.ingest_poll(&mut m, page);
3553+
3554+ let ts = m.messages[&peer][0].ts;
3555+ assert!(
3556+ ts <= now_ms() + MAX_FUTURE_SKEW_MS,
3557+ "ts {ts} must be clamped into the skew window"
3558+ );
3559+ assert!(ts >= before, "and clamped to now, not to some past value");
3560+ // The clamped value is what the shell sorts and renders by.
3561+ assert_eq!(app.view(&m).contacts[0].last_ts, ts);
3562+ }
3563+
3564+ /// Honest clock skew inside the tolerance is preserved as-is.
3565+ #[test]
3566+ fn a_slightly_future_timestamp_is_kept() {
3567+ let app = Skrepka;
3568+ let mut m = with_identity();
3569+ let peer = peer_hex(9);
3570+ let ts = now_ms() + 5_000;
3571+
3572+ let page = page_from(9, &m, &text("m1", "hi"), ts);
3573+ let _ = app.ingest_poll(&mut m, page);
3574+
3575+ assert_eq!(m.messages[&peer][0].ts, ts);
3576+ }
3577+
3578+ #[test]
3579+ fn message_history_is_capped_at_the_cutoff() {
3580+ let app = Skrepka;
3581+ let mut m = with_identity();
3582+ let peer = peer_hex(9);
3583+
3584+ let convo = m.messages.entry(peer.clone()).or_default();
3585+ for i in 0..MAX_MESSAGES_PER_PEER {
3586+ convo.push(StoredMessage {
3587+ id: format!("old-{i}"),
3588+ body: "x".into(),
3589+ ts: i as i64,
3590+ outgoing: false,
3591+ delivered: false,
3592+ });
3593+ }
3594+
3595+ let page = page_from(9, &m, &text("new", "newest"), now_ms());
3596+ let _ = app.ingest_poll(&mut m, page);
3597+
3598+ let convo = &m.messages[&peer];
3599+ assert_eq!(convo.len(), MAX_MESSAGES_PER_PEER, "the cap holds");
3600+ assert_eq!(convo.last().unwrap().id, "new", "the newest is kept");
3601+ assert_eq!(convo.first().unwrap().id, "old-1", "the oldest is aged out");
3602+ }
3603+
3604+ /// An off-curve key can't be encrypted to, so the send is dropped — but the
3605+ /// user has to be told, rather than watching the message vanish.
3606+ #[test]
3607+ fn a_send_to_an_unusable_key_reports_an_error() {
3608+ let app = Skrepka;
3609+ let mut m = with_identity();
3610+ m.token = Some("t".into());
3611+ let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
3612+ m.outbox
3613+ .push_back(OutboxItem::new(bad, Arc::new("{}".into())));
3614+
3615+ let _ = app.update(Event::StartFlush, &mut m);
3616+
3617+ assert!(m.outbox.is_empty(), "the unsendable item does not wedge the outbox");
3618+ assert!(!m.flushing);
3619+ let err = m.error.expect("the failure is surfaced");
3620+ assert!(err.contains("invalid key"), "got: {err}");
3621+ }
3622+
3623+ #[test]
3624+ fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
3625+ let app = Skrepka;
3626+ let mut m = with_identity();
3627+ let bad = hex::encode([0xabu8; 32]);
3628+
3629+ let _ = app.update(
3630+ Event::AddContact {
3631+ input: bad.clone(),
3632+ nickname: "Mallory".into(),
3633+ },
3634+ &mut m,
3635+ );
3636+
3637+ assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
3638+ assert!(m.error.is_some());
3639+ }
3640+
3641+ /// A dropped kv write silently loses data; it must not be swallowed.
3642+ #[test]
3643+ fn a_failed_save_surfaces_an_error() {
3644+ let app = Skrepka;
3645+ let mut m = with_identity();
3646+
3647+ let _ = app.update(
3648+ Event::Saved(Err(crux_kv::error::KeyValueError::Io {
3649+ message: "no space left on device".into(),
3650+ })),
3651+ &mut m,
3652+ );
3653+ assert!(m.error.is_some(), "a failed write is reported");
3654+
3655+ // A successful write stays quiet.
3656+ m.error = None;
3657+ let _ = app.update(Event::Saved(Ok(None)), &mut m);
3658+ assert!(m.error.is_none());
3659+ }
3660+
3661+ #[test]
3662+ fn server_host_strips_scheme_port_and_case() {
3663+ assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
3664+ assert_eq!(server_host("http://localhost:8080"), "localhost");
3665+ assert_eq!(server_host("https://relay.example.com./x"), "relay.example.com");
3666+ // Its only caller passes `settings.server_url`, which is always an
3667+ // absolute URL (`normalize_server_url` guarantees it). Anything else is a
3668+ // bug, and an empty host produces a signature the relay rejects — which is
3669+ // the right failure: loud, not a silently-wrong host binding.
3670+ assert_eq!(server_host("relay.example.com"), "");
3671+ }
3672+
3673+ /// The auth signature is bound to the host (PROTOCOL.md §6), so `server_host`
3674+ /// has to agree with what the relay computes. Splitting on `:` by hand cut an
3675+ /// IPv6 literal at its first colon and produced `[`.
3676+ #[test]
3677+ fn server_host_handles_an_ipv6_literal() {
3678+ assert_eq!(server_host("http://[::1]:8080"), "[::1]");
3679+ assert_eq!(
3680+ server_host("https://[2001:DB8::1]/poll"),
3681+ "[2001:db8::1]"
3682+ );
3683+ }
3684+
3685+ #[test]
3686+ fn backoff_is_capped() {
3687+ assert_eq!(backoff_ms(0), 3000);
3688+ assert_eq!(backoff_ms(1), 6000);
3689+ assert_eq!(backoff_ms(3), 24000);
3690+ assert_eq!(backoff_ms(10), 30000);
3691+ }
3692+
3693+ #[test]
3694+ fn add_contact_accepts_hex_and_rejects_self() {
3695+ let app = Skrepka;
3696+ let mut m = with_identity();
3697+ let peer = peer_hex(9);
3698+ let _ = app.update(
3699+ Event::AddContact {
3700+ input: peer.clone(),
3701+ nickname: "Bob".into(),
3702+ },
3703+ &mut m,
3704+ );
3705+ assert!(m.contacts.contains_key(&peer));
3706+ assert_eq!(m.contacts[&peer].nickname, "Bob");
3707+
3708+ // adding own key is rejected with an error
3709+ let mine = m.my_pubkey.clone();
3710+ let _ = app.update(
3711+ Event::AddContact {
3712+ input: mine,
3713+ nickname: String::new(),
3714+ },
3715+ &mut m,
3716+ );
3717+ assert!(m.error.is_some());
3718+ }
3719+
3720+ /// A stale error from a previous failed add is cleared on the next attempt,
3721+ /// so the AddContact form does not show an old error after the user fixes
3722+ /// the input and tries again.
3723+ #[test]
3724+ fn add_contact_clears_a_stale_error_on_success() {
3725+ let app = Skrepka;
3726+ let mut m = with_identity();
3727+ m.error = Some("invalid public key".into());
3728+
3729+ let peer = peer_hex(9);
3730+ let _ = app.update(
3731+ Event::AddContact {
3732+ input: peer.clone(),
3733+ nickname: "Bob".into(),
3734+ },
3735+ &mut m,
3736+ );
3737+
3738+ assert!(m.error.is_none(), "a successful add clears the stale error");
3739+ assert!(m.contacts.contains_key(&peer));
3740+ }
3741+
3742+ #[test]
3743+ fn send_text_enqueues_message_profile_and_outbox() {
3744+ let app = Skrepka;
3745+ let mut m = with_identity();
3746+ let peer = peer_hex(9);
3747+ m.active_peer = Some(peer.clone());
3748+ m.compose = "hello".into();
3749+
3750+ let _ = app.update(Event::SendText, &mut m);
3751+
3752+ // one outgoing message recorded
3753+ let convo = &m.messages[&peer];
3754+ assert_eq!(convo.len(), 1);
3755+ assert!(convo[0].outgoing);
3756+ assert_eq!(convo[0].body, "hello");
3757+ // compose cleared, contact auto-created
3758+ assert!(m.compose.is_empty());
3759+ assert!(m.contacts.contains_key(&peer));
3760+ // outbox has the text + a first-contact profile
3761+ assert_eq!(m.outbox.len(), 2);
3762+ assert_eq!(m.outbox[0].recipient, peer);
3763+ }
3764+
3765+ #[test]
3766+ fn send_text_without_peer_or_empty_is_noop() {
3767+ let app = Skrepka;
3768+ let mut m = with_identity();
3769+ m.compose = "x".into();
3770+ let _ = app.update(Event::SendText, &mut m); // no active peer
3771+ assert!(m.outbox.is_empty());
3772+
3773+ m.active_peer = Some(peer_hex(9));
3774+ m.compose = " ".into();
3775+ let _ = app.update(Event::SendText, &mut m); // whitespace only
3776+ assert!(m.outbox.is_empty());
3777+ }
3778+
3779+ /// The contact cap must be checked *before* mutating anything. The old
3780+ /// order cleared `compose` and inserted the message into `model.messages`
3781+ /// first, then hit the cap and returned early — losing the user's text and
3782+ /// leaving an invisible orphan conversation that was never persisted.
3783+ #[test]
3784+ fn send_text_to_a_new_peer_at_the_contact_cap_preserves_compose() {
3785+ let app = Skrepka;
3786+ let mut m = with_identity();
3787+ // Fill the contact list to the cap.
3788+ for i in 0..MAX_CONTACTS {
3789+ let k = format!("{i:064x}");
3790+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
3791+ }
3792+ let new_peer = peer_hex(9);
3793+ m.active_peer = Some(new_peer.clone());
3794+ m.compose = "hello new friend".into();
3795+
3796+ let _ = app.update(Event::SendText, &mut m);
3797+
3798+ assert_eq!(
3799+ m.compose, "hello new friend",
3800+ "compose must not be cleared on rejection"
3801+ );
3802+ assert!(
3803+ m.messages.get(&new_peer).is_none_or(Vec::is_empty),
3804+ "no orphan conversation must be created"
3805+ );
3806+ assert!(m.outbox.is_empty(), "nothing must be queued");
3807+ assert!(!m.contacts.contains_key(&new_peer), "no contact created");
3808+ assert!(
3809+ m.error.as_ref().is_some_and(|e| e.contains("full")),
3810+ "the user is told why"
3811+ );
3812+ }
3813+
3814+ #[test]
3815+ fn set_blocked_toggles_and_view_reflects_it() {
3816+ let app = Skrepka;
3817+ let mut m = with_identity();
3818+ let peer = peer_hex(9);
3819+ m.contacts
3820+ .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
3821+ let _ = app.update(
3822+ Event::SetBlocked {
3823+ peer: peer.clone(),
3824+ blocked: true,
3825+ },
3826+ &mut m,
3827+ );
3828+ assert!(m.contacts[&peer].blocked);
3829+ let vm = app.view(&m);
3830+ assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
3831+ }
3832+
3833+ /// Unblocking clears a stale "cannot send to a blocked contact" error, so the
3834+ /// chat error display does not persist after the user lifts the block.
3835+ #[test]
3836+ fn unblocking_clears_the_stale_send_error() {
3837+ let app = Skrepka;
3838+ let mut m = with_identity();
3839+ let peer = peer_hex(9);
3840+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
3841+ contact.blocked = true;
3842+ m.contacts.insert(peer.clone(), contact);
3843+ m.error = Some("cannot send to a blocked contact".into());
3844+
3845+ let _ = app.update(
3846+ Event::SetBlocked {
3847+ peer: peer.clone(),
3848+ blocked: false,
3849+ },
3850+ &mut m,
3851+ );
3852+
3853+ assert!(!m.contacts[&peer].blocked);
3854+ assert!(m.error.is_none(), "unblocking clears the stale error");
3855+ }
3856+
3857+ /// Gating ingest stops the acks we have not queued yet — but the ack for the
3858+ /// message that made the user hit "block" is already in the outbox, and an ack
3859+ /// is exactly the "we are online and reading you" signal the block exists to
3860+ /// cut off.
3861+ #[test]
3862+ fn blocking_a_peer_drops_what_is_already_queued_for_them() {
3863+ let app = Skrepka;
3864+ let mut m = with_identity();
3865+ let enemy = peer_hex(9);
3866+ let friend = peer_hex(11);
3867+ m.contacts
3868+ .insert(enemy.clone(), Contact::new(enemy.clone(), String::new(), 0));
3869+
3870+ // An ack to the peer we are about to block, plus unrelated mail.
3871+ for recipient in [&enemy, &friend] {
3872+ m.outbox.push_back(OutboxItem::new(
3873+ recipient.clone(),
3874+ Arc::new(protocol::serialize_payload(
3875+ &Payload::DeliveryAck {
3876+ ack_ids: vec!["m1".into()],
3877+ },
3878+ 1,
3879+ )),
3880+ ));
3881+ }
3882+
3883+ let _ = app.update(
3884+ Event::SetBlocked {
3885+ peer: enemy.clone(),
3886+ blocked: true,
3887+ },
3888+ &mut m,
3889+ );
3890+
3891+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
3892+ assert_eq!(
3893+ recipients,
3894+ vec![friend.as_str()],
3895+ "nothing more goes to a blocked peer — and everyone else is untouched"
3896+ );
3897+ }
3898+
3899+ #[test]
3900+ fn normalize_server_url_requires_an_http_scheme() {
3901+ // The real parser canonicalizes: scheme and host are lowercased.
3902+ assert_eq!(
3903+ normalize_server_url(" https://Relay.Example.com/ "),
3904+ Some("https://relay.example.com".to_string())
3905+ );
3906+ assert_eq!(
3907+ normalize_server_url("HTTP://localhost:8080///"),
3908+ Some("http://localhost:8080".to_string())
3909+ );
3910+ assert_eq!(
3911+ normalize_server_url("http://[::1]:8080"),
3912+ Some("http://[::1]:8080".to_string())
3913+ );
3914+ // The cases that used to panic Http::post or produce a dead URL.
3915+ assert_eq!(normalize_server_url("relay.example.com"), None);
3916+ assert_eq!(normalize_server_url("localhost:8080"), None);
3917+ assert_eq!(normalize_server_url("ftp://relay.example.com"), None);
3918+ assert_eq!(normalize_server_url("http://"), None);
3919+ assert_eq!(normalize_server_url("http://:8080"), None);
3920+ assert_eq!(normalize_server_url("https://relay example.com"), None);
3921+ assert_eq!(normalize_server_url(""), None);
3922+ }
3923+
3924+ /// The hand-rolled validator this replaces was not the parser it guarded:
3925+ /// it waved these through, and `crux_http` unwraps the parse.
3926+ #[test]
3927+ fn normalize_server_url_rejects_what_the_url_parser_rejects() {
3928+ assert_eq!(normalize_server_url("http://[bad"), None);
3929+ assert_eq!(normalize_server_url("http://ho^st"), None);
3930+ assert_eq!(normalize_server_url("http://a\u{7f}b"), None);
3931+ }
3932+
3933+ /// Every request appends a path, so a query or fragment would silently
3934+ /// swallow it: `http://x?y` + "/poll" is `http://x?y/poll` — a request to `/`
3935+ /// with a junk query, not to the poll endpoint.
3936+ #[test]
3937+ fn normalize_server_url_rejects_a_query_or_fragment() {
3938+ assert_eq!(normalize_server_url("http://x?y"), None);
3939+ assert_eq!(normalize_server_url("https://relay.example.com/#frag"), None);
3940+ }
3941+
3942+ #[test]
3943+ fn set_server_url_rejects_schemeless_and_keeps_the_old_url() {
3944+ let app = Skrepka;
3945+ let mut m = with_identity();
3946+ let original = m.settings.server_url.clone();
3947+
3948+ let _ = app.update(Event::SetServerUrl("relay.example.com".into()), &mut m);
3949+ assert_eq!(m.settings.server_url, original, "bad URL must not persist");
3950+ assert!(m.error.is_some());
3951+
3952+ let _ = app.update(
3953+ Event::SetServerUrl(" https://relay.example.com/ ".into()),
3954+ &mut m,
3955+ );
3956+ assert_eq!(m.settings.server_url, "https://relay.example.com");
3957+ assert!(m.error.is_none());
3958+ assert!(m.token.is_none());
3959+ assert_eq!(m.conn, ConnStatus::Offline);
3960+ }
3961+
3962+ /// The regression that stalled the outbox: `flushing` stayed `true` forever
3963+ /// after the first successful send, so nothing was ever sent again.
3964+ #[test]
3965+ fn send_result_ok_clears_flushing() {
3966+ let app = Skrepka;
3967+ let mut m = with_identity();
3968+ m.token = Some("t".into());
3969+ m.outbox
3970+ .push_back(OutboxItem::new(peer_hex(9), Arc::new("{}".into())));
3971+
3972+ let _ = app.update(Event::StartFlush, &mut m);
3973+ assert!(m.flushing);
3974+
3975+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3976+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3977+
3978+ assert!(!m.flushing, "flushing must be cleared on the success path");
3979+ assert!(m.outbox.is_empty(), "the sent item is dropped from the outbox");
3980+ }
3981+
3982+ #[test]
3983+ fn flush_next_resumes_after_a_successful_send() {
3984+ let app = Skrepka;
3985+ let mut m = with_identity();
3986+ m.token = Some("t".into());
3987+ // Two different peers, or the batching would send both at once.
3988+ for seed in [9u8, 11] {
3989+ m.outbox
3990+ .push_back(OutboxItem::new(peer_hex(seed), Arc::new("{}".into())));
3991+ }
3992+ // First send goes out and marks the model as flushing.
3993+ let _ = app.update(Event::StartFlush, &mut m);
3994+ assert!(m.flushing);
3995+
3996+ // Its success must both drop the item and let the *next* one go out.
3997+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
3998+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(resp)), &mut m);
3999+ assert_eq!(m.outbox.len(), 1);
4000+ assert!(m.flushing, "the second item is now in flight");
4001+ }
4002+
4003+ #[test]
4004+ fn poll_is_a_noop_while_a_poll_is_already_in_flight() {
4005+ let app = Skrepka;
4006+ let mut m = with_identity();
4007+ m.token = Some("t".into());
4008+
4009+ let _ = app.update(Event::Poll, &mut m);
4010+ assert!(m.polling, "the first Poll issues a request");
4011+ let gen = m.poll_gen;
4012+
4013+ // A second Poll (e.g. from a re-auth) must not stack another loop.
4014+ let _ = app.update(Event::Poll, &mut m);
4015+ assert!(m.polling);
4016+ assert_eq!(m.poll_gen, gen, "and does not open a new generation");
4017+
4018+ // Both success and failure release the guard so the loop can continue.
4019+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4020+ let _ = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4021+ assert!(!m.polling);
4022+
4023+ let _ = app.update(Event::Poll, &mut m);
4024+ assert!(m.polling);
4025+ let _ = app.update(
4026+ Event::PollResult(m.poll_gen, Err(crux_http::HttpError::Timeout)),
4027+ &mut m,
4028+ );
4029+ assert!(!m.polling);
4030+ }
4031+
4032+ /// The cursor is the *relay's* sequence number, and the next poll acks
4033+ /// everything up to it. Carrying relay A's cursor over to relay B tells B to
4034+ /// delete every message waiting for us before handing any of them over.
4035+ #[test]
4036+ fn switching_relays_resets_the_cursor() {
4037+ let app = Skrepka;
4038+ let mut m = with_identity();
4039+ m.cursor = now_ms();
4040+ m.token = Some("t".into());
4041+ m.conn = ConnStatus::Online;
4042+
4043+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4044+
4045+ assert_eq!(m.cursor, 0, "the new relay is polled from the beginning");
4046+ assert!(m.token.is_none(), "the old relay's token is worthless");
4047+ assert_eq!(m.conn, ConnStatus::Offline);
4048+ }
4049+
4050+ /// Resetting the cursor is not enough on its own: the poll already in flight
4051+ /// is addressed to the *old* relay and its page carries the old relay's
4052+ /// sequence number. Letting it land writes that number straight back over the
4053+ /// reset, and the next poll hands it to the new relay — which reads it as an
4054+ /// ack and drops every message queued for us before serving any of them.
4055+ #[test]
4056+ fn switching_relays_retires_the_poll_still_in_flight_at_the_old_one() {
4057+ let app = Skrepka;
4058+ let mut m = with_identity();
4059+ m.token = Some("t".into());
4060+ m.conn = ConnStatus::Online;
4061+
4062+ let _ = app.update(Event::Poll, &mut m);
4063+ let old_relay_poll = m.poll_gen;
4064+ assert!(m.polling);
4065+
4066+ let _ = app.update(Event::SetServerUrl("https://other.example.com".into()), &mut m);
4067+ assert!(!m.polling, "the old relay's poll no longer holds the guard");
4068+
4069+ // It resolves after the switch, carrying the old relay's cursor.
4070+ let body = serde_json::to_vec(&serde_json::json!({
4071+ "events": [],
4072+ "cursor": 1_700_000_000_000i64,
4073+ }))
4074+ .unwrap();
4075+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4076+ let _ = app.update(Event::PollResult(old_relay_poll, Ok(resp)), &mut m);
4077+
4078+ assert_eq!(
4079+ m.cursor, 0,
4080+ "the new relay must not be handed a cursor from a sequence it never issued"
4081+ );
4082+ }
4083+
4084+ /// A URL that the parser rejects reaches `Http::post`, which unwraps it. On
4085+ /// disk it is a panic on every launch — a boot loop with no way out.
4086+ #[test]
4087+ fn a_corrupt_persisted_server_url_falls_back_to_the_default() {
4088+ let app = Skrepka;
4089+ let mut m = with_identity();
4090+ m.loads_pending = STARTUP_LOADS;
4091+
4092+ let junk = serde_json::to_vec(&serde_json::json!({"server_url": "http://[bad"})).unwrap();
4093+ let _ = app.update(Event::LoadedSettings(Ok(Some(junk))), &mut m);
4094+
4095+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4096+ assert!(
4097+ normalize_server_url(&m.settings.server_url).is_some(),
4098+ "whatever we end up with must survive the parser"
4099+ );
4100+ }
4101+
4102+ /// A settings blob written before a field existed must still load — otherwise
4103+ /// `parse_kv` returns None, the default is used, and the next write destroys
4104+ /// what was there.
4105+ #[test]
4106+ fn an_old_settings_blob_still_loads() {
4107+ let app = Skrepka;
4108+ let mut m = with_identity();
4109+ m.loads_pending = STARTUP_LOADS;
4110+
4111+ let empty = serde_json::to_vec(&serde_json::json!({})).unwrap();
4112+ let _ = app.update(Event::LoadedSettings(Ok(Some(empty))), &mut m);
4113+ assert_eq!(m.settings.server_url, DEFAULT_SERVER_URL);
4114+
4115+ // ...and a Contact missing every optional field still becomes a contact.
4116+ let peer = peer_hex(9);
4117+ let old = serde_json::to_vec(&serde_json::json!([{"pubkey": peer}])).unwrap();
4118+ let _ = app.update(Event::LoadedContacts(Ok(Some(old))), &mut m);
4119+ assert!(m.contacts.contains_key(&peer));
4120+ }
4121+
4122+ /// Connecting before the startup loads land lets a poll be ingested against a
4123+ /// half-loaded model — and the loads still in flight then overwrite it.
4124+ #[test]
4125+ fn connect_waits_for_every_startup_load() {
4126+ let app = Skrepka;
4127+ let mut m = with_identity();
4128+ m.loads_pending = STARTUP_LOADS;
4129+
4130+ let connects = |cmd: &mut Command<Effect, Event>| {
4131+ cmd.events()
4132+ .filter(|e| matches!(e, Event::Connect))
4133+ .count()
4134+ };
4135+
4136+ for (i, ev) in [
4137+ Event::LoadedSettings(Ok(None)),
4138+ Event::LoadedProfile(Ok(None)),
4139+ Event::LoadedContacts(Ok(None)),
4140+ Event::LoadedCursor(Ok(None)),
4141+ Event::LoadedSeenIds(Ok(None)),
4142+ ]
4143+ .into_iter()
4144+ .enumerate()
4145+ {
4146+ let mut cmd = app.update(ev, &mut m);
4147+ assert_eq!(
4148+ m.loads_pending,
4149+ STARTUP_LOADS - 1 - i as u8,
4150+ "each load ticks the counter down"
4151+ );
4152+ assert_eq!(connects(&mut cmd), 0, "and none of them connects");
4153+ }
4154+
4155+ // The sixth — and only the sixth — emits Connect.
4156+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4157+ assert_eq!(m.loads_pending, 0);
4158+ assert_eq!(connects(&mut cmd), 1, "the last load connects, exactly once");
4159+
4160+ // A late duplicate (a re-delivered load) must not connect again.
4161+ let mut cmd = app.update(Event::LoadedOutbox(Ok(None)), &mut m);
4162+ assert_eq!(connects(&mut cmd), 0);
4163+ }
4164+
4165+ /// The sharpest edge of a failed read, and the one that costs the user real
4166+ /// messages: `ingest_poll` persists the outbox on *every* poll page, including
4167+ /// an empty one. So a single unreadable outbox — a background wake with the
4168+ /// device locked — followed by one idle poll used to write `[]` over a queue of
4169+ /// unsent mail. The client destroys the user's messages by doing nothing at all.
4170+ ///
4171+ /// A read failure is not an empty key. Nothing may be written until a launch
4172+ /// reads the key successfully.
4173+ #[test]
4174+ fn a_failed_read_never_writes_over_what_it_could_not_read() {
4175+ let app = Skrepka;
4176+ let mut m = with_identity();
4177+ m.loads_pending = STARTUP_LOADS;
4178+
4179+ let connects = |cmd: &mut Command<Effect, Event>| {
4180+ cmd.events()
4181+ .filter(|e| matches!(e, Event::Connect))
4182+ .count()
4183+ };
4184+ // Every key a command actually writes. The model's own view of what it saved
4185+ // cannot see these — the write lives entirely in the effect.
4186+ let kv_writes = |cmd: &mut Command<Effect, Event>| {
4187+ cmd.effects()
4188+ .filter_map(|e| match e {
4189+ Effect::KeyValue(req) => match &req.operation {
4190+ crux_kv::KeyValueOperation::Set { key, .. } => Some(key.clone()),
4191+ _ => None,
4192+ },
4193+ _ => None,
4194+ })
4195+ .collect::<Vec<_>>()
4196+ };
4197+
4198+ let _ = app.update(Event::LoadedSettings(Ok(None)), &mut m);
4199+ let _ = app.update(Event::LoadedProfile(Ok(None)), &mut m);
4200+ let _ = app.update(Event::LoadedContacts(Ok(None)), &mut m);
4201+ let _ = app.update(Event::LoadedCursor(Ok(None)), &mut m);
4202+ // The outbox is the one that could not be read — a background wake with the
4203+ // device locked. The model now believes it is empty; the disk says otherwise.
4204+ let mut cmd = app.update(
4205+ Event::LoadedOutbox(Err(crux_kv::error::KeyValueError::Io {
4206+ message: "device is locked".into(),
4207+ })),
4208+ &mut m,
4209+ );
4210+
4211+ assert!(m.kv_load_failed, "the model knows it has a hole in it");
4212+ assert!(m.error.is_some(), "and says so");
4213+ assert_eq!(
4214+ connects(&mut cmd),
4215+ 0,
4216+ "the last load does not connect: a poll would ack mail we cannot store"
4217+ );
4218+
4219+ // Every write path is closed, whatever tries to open it.
4220+ let mut cmd = app.update(
4221+ Event::AddContact {
4222+ input: peer_hex(9),
4223+ nickname: "n".into(),
4224+ },
4225+ &mut m,
4226+ );
4227+ assert!(
4228+ kv_writes(&mut cmd).is_empty(),
4229+ "contacts are not written over an unread model"
4230+ );
4231+
4232+ // And the regression itself. `ingest_poll` persists the outbox on every page,
4233+ // so one idle poll used to be enough to write `[]` over the queue on disk.
4234+ m.token = Some("t".into());
4235+ let resp = crux_http::testing::ResponseBuilder::ok()
4236+ .body(br#"{"events":[],"cursor":42}"#.to_vec())
4237+ .build();
4238+ let mut cmd = app.update(Event::PollResult(m.poll_gen, Ok(resp)), &mut m);
4239+ assert!(
4240+ kv_writes(&mut cmd).is_empty(),
4241+ "an empty poll writes nothing — not the outbox, not the cursor"
4242+ );
4243+
4244+ // The recovery is a relaunch, which re-runs the loads and clears the latch.
4245+ let sk = Identity::from_seed(&[3u8; 32]).unwrap().secret_key.to_vec();
4246+ let _ = app.update(Event::IdentityLoaded(sk), &mut m);
4247+ assert!(
4248+ !m.kv_load_failed,
4249+ "a fresh round of loads gets to answer for itself"
4250+ );
4251+ }
4252+
4253+ /// `Authenticate` is reachable from startup, a 401 on poll, a 401 on send and
4254+ /// a reconnect timer. Without a guard a network blip fires all of them, and
4255+ /// every token that comes back starts its own poll loop.
4256+ #[test]
4257+ fn authentication_does_not_stack() {
4258+ let app = Skrepka;
4259+ let mut m = with_identity();
4260+
4261+ let _ = app.update(Event::Authenticate, &mut m);
4262+ assert!(m.authenticating, "the first one goes out");
4263+ assert_eq!(m.conn, ConnStatus::Connecting);
4264+
4265+ // A second Authenticate while the first is in flight is a no-op.
4266+ m.conn = ConnStatus::Offline;
4267+ let _ = app.update(Event::Authenticate, &mut m);
4268+ assert_eq!(m.conn, ConnStatus::Offline, "no second challenge was issued");
4269+
4270+ // Any terminal outcome releases the guard.
4271+ let _ = app.update(
4272+ Event::ChallengeResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4273+ &mut m,
4274+ );
4275+ assert!(!m.authenticating);
4276+
4277+ let _ = app.update(Event::Authenticate, &mut m);
4278+ assert!(m.authenticating);
4279+ let _ = app.update(
4280+ Event::VerifyResult(m.auth_gen, Err(crux_http::HttpError::Timeout)),
4281+ &mut m,
4282+ );
4283+ assert!(!m.authenticating);
4284+ }
4285+
4286+ /// The shell fires `Connect` on *every* return to the foreground
4287+ /// (`SkrepkaApp.swift`), so a challenge issued before the app was backgrounded
4288+ /// is routinely still in flight when the next attempt starts. It must not come
4289+ /// back and install a token, or clobber `conn`, on top of the attempt that
4290+ /// replaced it.
4291+ #[test]
4292+ fn an_auth_round_trip_superseded_by_connect_cannot_land() {
4293+ let app = Skrepka;
4294+ let mut m = with_identity();
4295+
4296+ let _ = app.update(Event::Authenticate, &mut m);
4297+ let superseded = m.auth_gen;
4298+ assert!(m.authenticating);
4299+
4300+ // Foregrounding restarts the flow.
4301+ dispatch(&app, Event::Connect, &mut m);
4302+ let live = m.auth_gen;
4303+ assert_ne!(live, superseded, "the in-flight attempt was retired");
4304+ assert!(m.authenticating, "and a fresh one went out");
4305+
4306+ // The old challenge finally answers. It is not ours any more.
4307+ let body = serde_json::to_vec(&serde_json::json!({"challenge": "deadbeef"})).unwrap();
4308+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4309+ let _ = app.update(Event::ChallengeResult(superseded, Ok(resp)), &mut m);
4310+ assert!(
4311+ m.authenticating,
4312+ "the live attempt still holds the guard — a stale leg must not release it"
4313+ );
4314+
4315+ // Nor may a stale *verify* install a token over the live attempt.
4316+ let body = serde_json::to_vec(&serde_json::json!({"token": "stale"})).unwrap();
4317+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4318+ let _ = app.update(Event::VerifyResult(superseded, Ok(resp)), &mut m);
4319+ assert!(m.token.is_none(), "no token from a superseded attempt");
4320+ assert_ne!(m.conn, ConnStatus::Online);
4321+
4322+ // The live attempt still completes normally.
4323+ let body = serde_json::to_vec(&serde_json::json!({"token": "real"})).unwrap();
4324+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4325+ let _ = app.update(Event::VerifyResult(live, Ok(resp)), &mut m);
4326+ assert_eq!(m.token.as_deref(), Some("real"));
4327+ assert_eq!(m.conn, ConnStatus::Online);
4328+ }
4329+
4330+ /// Blocking is meant to cut a peer off in *both* directions.
4331+ #[test]
4332+ fn a_blocked_peer_cannot_update_their_profile_or_ack_our_messages() {
4333+ let app = Skrepka;
4334+ let mut m = with_identity();
4335+ let peer = peer_hex(9);
4336+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4337+ contact.blocked = true;
4338+ contact.display_name = "Bob".into();
4339+ m.contacts.insert(peer.clone(), contact);
4340+ m.messages.insert(
4341+ peer.clone(),
4342+ vec![StoredMessage {
4343+ id: "mine".into(),
4344+ body: "hi".into(),
4345+ ts: 1,
4346+ outgoing: true,
4347+ delivered: false,
4348+ }],
4349+ );
4350+
4351+ let profile = Payload::Profile {
4352+ display_name: Some("Mallory".into()),
4353+ bio: Some("spam".into()),
4354+ photo: Some("aGk=".into()),
4355+ };
4356+ let page = page_from(9, &m, &profile, now_ms());
4357+ let _ = app.ingest_poll(&mut m, page);
4358+ assert_eq!(
4359+ m.contacts[&peer].display_name, "Bob",
4360+ "a blocked peer cannot rewrite their entry in our contact list"
4361+ );
4362+
4363+ let ack = Payload::DeliveryAck {
4364+ ack_ids: vec!["mine".into()],
4365+ };
4366+ let page = page_from(9, &m, &ack, now_ms());
4367+ let _ = app.ingest_poll(&mut m, page);
4368+ assert!(
4369+ !m.messages[&peer][0].delivered,
4370+ "nor tell us they read what we sent before the block"
4371+ );
4372+ }
4373+
4374+ /// Blocking someone and then updating our profile must not hand them our new
4375+ /// display name, avatar, and a liveness signal.
4376+ #[test]
4377+ fn save_profile_does_not_broadcast_to_blocked_contacts() {
4378+ let app = Skrepka;
4379+ let mut m = with_identity();
4380+ let friend = peer_hex(9);
4381+ let enemy = peer_hex(11);
4382+ m.contacts
4383+ .insert(friend.clone(), Contact::new(friend.clone(), "Bob".into(), 0));
4384+ let mut blocked = Contact::new(enemy.clone(), "Mallory".into(), 0);
4385+ blocked.blocked = true;
4386+ m.contacts.insert(enemy.clone(), blocked);
4387+
4388+ let _ = app.update(
4389+ Event::SaveProfile {
4390+ display_name: "Alice".into(),
4391+ bio: String::new(),
4392+ photo: None,
4393+ },
4394+ &mut m,
4395+ );
4396+
4397+ let recipients: Vec<&str> = m.outbox.iter().map(|o| o.recipient.as_str()).collect();
4398+ assert_eq!(recipients, vec![friend.as_str()]);
4399+ }
4400+
4401+ /// Any stranger can create a contact just by writing to us, so the list needs
4402+ /// a ceiling — and past it, the message goes too: a stored conversation with
4403+ /// no contact entry is one nothing can open and nothing ever trims.
4404+ #[test]
4405+ fn contacts_from_strangers_are_capped() {
4406+ let app = Skrepka;
4407+ let mut m = with_identity();
4408+ for i in 0..MAX_CONTACTS {
4409+ let k = format!("{i:064x}");
4410+ m.contacts.insert(k.clone(), Contact::new(k, String::new(), 0));
4411+ }
4412+ assert_eq!(m.contacts.len(), MAX_CONTACTS);
4413+
4414+ let peer = peer_hex(9);
4415+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4416+ let _ = app.ingest_poll(&mut m, page);
4417+
4418+ assert_eq!(m.contacts.len(), MAX_CONTACTS, "the cap holds");
4419+ assert!(!m.contacts.contains_key(&peer));
4420+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4421+ assert!(m.outbox.is_empty(), "and nothing is acked back");
4422+ }
4423+
4424+ /// A bare profile is an unsolicited push with no message attached. Honouring
4425+ /// it would let anyone holding our pubkey install an entry — with an avatar —
4426+ /// in our contact list without ever saying a word.
4427+ #[test]
4428+ fn a_profile_from_a_stranger_does_not_create_a_contact() {
4429+ let app = Skrepka;
4430+ let mut m = with_identity();
4431+ let peer = peer_hex(9);
4432+
4433+ let profile = Payload::Profile {
4434+ display_name: Some("Mallory".into()),
4435+ bio: Some(String::new()),
4436+ photo: Some("aGk=".into()),
4437+ };
4438+ let page = page_from(9, &m, &profile, now_ms());
4439+ let _ = app.ingest_poll(&mut m, page);
4440+ assert!(m.contacts.is_empty(), "no contact from a bare profile");
4441+
4442+ // But a profile from someone we *do* know still applies.
4443+ m.contacts
4444+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4445+ let page = page_from(9, &m, &profile, now_ms());
4446+ let _ = app.ingest_poll(&mut m, page);
4447+ assert_eq!(m.contacts[&peer].display_name, "Mallory");
4448+ }
4449+
4450+ /// A relay cannot forge mail from us — the sender identity is signed inside the
4451+ /// AEAD — but it can *echo back* a blob we sent through it, and one addressed to
4452+ /// ourselves decrypts perfectly well. Nothing downstream would notice: it would
4453+ /// file our own words as incoming, put our own key in our contact list (which
4454+ /// `AddContact` explicitly refuses), and queue a delivery ack addressed to us —
4455+ /// which flushes, comes back, and is acked again.
4456+ #[test]
4457+ fn a_message_from_ourselves_is_dropped() {
4458+ let app = Skrepka;
4459+ let mut m = with_identity();
4460+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4461+
4462+ let json = protocol::serialize_payload(&text("m1", "echo"), now_ms());
4463+ let blob = crate::crypto::encrypt(
4464+ &mut rand_core::OsRng,
4465+ &me,
4466+ &me.public_key(),
4467+ json.as_bytes(),
4468+ )
4469+ .unwrap();
4470+ let page = PollResp {
4471+ events: vec![PollEvent {
4472+ encrypted_blob: hex::encode(blob),
4473+ }],
4474+ cursor: 1,
4475+ };
4476+
4477+ let _ = app.ingest_poll(&mut m, page);
4478+
4479+ assert!(
4480+ !m.contacts.contains_key(&m.my_pubkey),
4481+ "we are not our own contact"
4482+ );
4483+ assert!(m.messages.is_empty(), "and our own words are not incoming mail");
4484+ assert!(m.outbox.is_empty(), "and we do not ack ourselves");
4485+ }
4486+
4487+ /// `polling` gates the whole long-poll loop and is cleared only by a
4488+ /// `PollResult`. If the shell ever drops the HTTP effect, the client goes
4489+ /// silent forever with no visible error.
4490+ #[test]
4491+ fn the_watchdog_unwedges_a_poll_that_never_resolved() {
4492+ let app = Skrepka;
4493+ let mut m = with_identity();
4494+ m.token = Some("t".into());
4495+
4496+ let _ = app.update(Event::Poll, &mut m);
4497+ assert!(m.polling);
4498+ let wedged = m.poll_gen;
4499+
4500+ // The watchdog armed for *this* poll releases the guard and restarts the
4501+ // loop — which issues a fresh poll under a new generation.
4502+ dispatch(&app, Event::PollWatchdog(wedged), &mut m);
4503+ assert!(m.polling, "the loop restarted");
4504+ assert_ne!(m.poll_gen, wedged, "under a new generation");
4505+ }
4506+
4507+ /// A watchdog is armed per poll and nothing cancels the earlier ones, so a
4508+ /// timer for a poll that has already completed keeps firing. It must not
4509+ /// touch the healthy poll that replaced it.
4510+ #[test]
4511+ fn a_stale_watchdog_leaves_the_current_poll_alone() {
4512+ let app = Skrepka;
4513+ let mut m = with_identity();
4514+ m.token = Some("t".into());
4515+
4516+ let _ = app.update(Event::Poll, &mut m);
4517+ let first = m.poll_gen;
4518+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4519+ let _ = app.update(Event::PollResult(first, Ok(resp)), &mut m);
4520+ let _ = app.update(Event::Poll, &mut m);
4521+ let current = m.poll_gen;
4522+ assert!(m.polling);
4523+
4524+ let _ = app.update(Event::PollWatchdog(first), &mut m);
4525+ assert!(m.polling, "the live poll is untouched");
4526+ assert_eq!(m.poll_gen, current, "and not superseded");
4527+ }
4528+
4529+ /// The regression the generation exists for.
4530+ ///
4531+ /// An HTTP effect cannot be cancelled, only abandoned — and it stays pending
4532+ /// across an iOS suspension while the wall clock runs on, so on resume the
4533+ /// watchdog gives up on a poll the shell is still about to resolve. If that
4534+ /// late `PollResult` were honoured it would clear `polling` — a guard that by
4535+ /// then belongs to the *replacement* poll — and chain a re-poll of its own.
4536+ /// Two loops, each re-polling itself forever, doubling on every recurrence.
4537+ #[test]
4538+ fn a_poll_abandoned_by_the_watchdog_cannot_fork_the_loop_when_it_lands() {
4539+ let app = Skrepka;
4540+ let mut m = with_identity();
4541+ m.token = Some("t".into());
4542+
4543+ let _ = app.update(Event::Poll, &mut m);
4544+ let abandoned = m.poll_gen;
4545+
4546+ // The watchdog gives up on it; the loop restarts under a new generation.
4547+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4548+ let live = m.poll_gen;
4549+ assert!(m.polling && live != abandoned);
4550+
4551+ // Now the abandoned request finally resolves. It must be ignored outright.
4552+ let resp = crux_http::testing::ResponseBuilder::ok().body(Vec::new()).build();
4553+ let mut cmd = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4554+
4555+ assert!(m.polling, "the live poll still holds the guard");
4556+ assert_eq!(m.poll_gen, live);
4557+ assert_eq!(
4558+ cmd.events().count(),
4559+ 0,
4560+ "and the corpse chains nothing — no second loop"
4561+ );
4562+ }
4563+
4564+ /// Dropping an abandoned page loses nothing: its cursor was never written, so
4565+ /// the relay still holds every message in it and re-delivers them.
4566+ #[test]
4567+ fn an_abandoned_poll_page_is_not_ingested() {
4568+ let app = Skrepka;
4569+ let mut m = with_identity();
4570+ m.token = Some("t".into());
4571+ let peer = peer_hex(9);
4572+
4573+ let _ = app.update(Event::Poll, &mut m);
4574+ let abandoned = m.poll_gen;
4575+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
4576+ let body = serde_json::to_vec(&serde_json::json!({
4577+ "events": [{"encryptedBlob": page.events[0].encrypted_blob}],
4578+ "cursor": 12345,
4579+ }))
4580+ .unwrap();
4581+
4582+ dispatch(&app, Event::PollWatchdog(abandoned), &mut m);
4583+ let resp = crux_http::testing::ResponseBuilder::ok().body(body).build();
4584+ let _ = app.update(Event::PollResult(abandoned, Ok(resp)), &mut m);
4585+
4586+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty));
4587+ assert_eq!(m.cursor, 0, "and the stale cursor is not adopted");
4588+ }
4589+
4590+ /// `view()` runs per keystroke (`ComposeChanged` renders), so cloning every
4591+ /// contact's base64 avatar each time meant megabytes of allocation to type one
4592+ /// message. Only the chat page has a composer, and while it is up the only
4593+ /// avatar on screen is the peer we are talking to — so that is the only page
4594+ /// that withholds anything.
4595+ ///
4596+ /// The conversations list draws an avatar per row (`ConversationsView.row`).
4597+ /// Blanking photos there buys nothing — no keystroke renders that page — and
4598+ /// costs every contact their picture.
4599+ #[test]
4600+ fn photos_are_withheld_on_the_chat_page_and_nowhere_else() {
4601+ let app = Skrepka;
4602+ let mut m = with_identity();
4603+ let a = peer_hex(9);
4604+ let b = peer_hex(11);
4605+ for peer in [&a, &b] {
4606+ let mut c = Contact::new(peer.clone(), String::new(), 0);
4607+ c.photo = Some("aGVsbG8=".into());
4608+ m.contacts.insert(peer.clone(), c);
4609+ }
4610+ let photo_of = |vm: &ViewModel, k: &str| {
4611+ vm.contacts
4612+ .iter()
4613+ .find(|c| c.pubkey == k)
4614+ .map(|c| c.photo.clone())
4615+ .unwrap()
4616+ };
4617+
4618+ // Conversations list: every row keeps its avatar.
4619+ let vm = app.view(&m);
4620+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4621+ assert_eq!(photo_of(&vm, &b), "aGVsbG8=");
4622+
4623+ // Open a chat: exactly that peer's photo, and no one else's — this is the
4624+ // page that re-renders on every keystroke.
4625+ m.page = Page::Chat;
4626+ m.active_peer = Some(a.clone());
4627+ let vm = app.view(&m);
4628+ assert_eq!(photo_of(&vm, &a), "aGVsbG8=");
4629+ assert!(photo_of(&vm, &b).is_empty());
4630+ }
4631+
4632+ /// `parse_payload` drops a `delivery.ack` with more than `MAX_ACK_IDS` ids, and
4633+ /// a peer running this code applies that cap to us. One ack payload per sender
4634+ /// therefore stops working the moment a peer has more than the cap waiting for
4635+ /// us — a long offline stretch — and those messages stay un-delivered on their
4636+ /// side forever, because a page is only ever acked once.
4637+ #[test]
4638+ fn acks_are_batched_so_the_peer_can_actually_parse_them() {
4639+ let app = Skrepka;
4640+ let mut m = with_identity();
4641+ let peer = peer_hex(9);
4642+ m.contacts
4643+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4644+
4645+ // One page holding more messages from one sender than fits in a single ack.
4646+ let count = protocol::MAX_ACK_IDS + 5;
4647+ let sender = Identity::from_seed(&[9u8; 32]).unwrap();
4648+ let me = Identity::from_secret_bytes(m.secret_key.as_ref().unwrap()).unwrap();
4649+ let events = (0..count)
4650+ .map(|i| {
4651+ let json = protocol::serialize_payload(&text(&format!("m{i}"), "hi"), now_ms());
4652+ let blob = crate::crypto::encrypt(
4653+ &mut rand_core::OsRng,
4654+ &sender,
4655+ &me.public_key(),
4656+ json.as_bytes(),
4657+ )
4658+ .unwrap();
4659+ PollEvent {
4660+ encrypted_blob: hex::encode(blob),
4661+ }
4662+ })
4663+ .collect();
4664+ let _ = app.ingest_poll(&mut m, PollResp { events, cursor: 1 });
4665+
4666+ // Local history is capped as always — but every message we *received* is
4667+ // acked, whether or not we chose to keep it.
4668+ assert_eq!(m.messages[&peer].len(), MAX_MESSAGES_PER_PEER);
4669+ assert_eq!(m.outbox.len(), 2, "the acks are split into parseable batches");
4670+
4671+ // And every batch really does survive the recipient's parser.
4672+ let mut acked = 0;
4673+ for item in &m.outbox {
4674+ assert_eq!(item.recipient, peer);
4675+ let parsed = protocol::parse_payload(item.envelope_json.as_bytes())
4676+ .expect("a peer must be able to parse the ack we send it");
4677+ match parsed.payload {
4678+ Payload::DeliveryAck { ack_ids } => {
4679+ assert!(ack_ids.len() <= protocol::MAX_ACK_IDS);
4680+ acked += ack_ids.len();
4681+ }
4682+ _ => panic!("expected a delivery.ack"),
4683+ }
4684+ }
4685+ assert_eq!(acked, count, "and between them they ack the whole page");
4686+ }
4687+
4688+ #[test]
4689+ fn view_exposes_identity_and_ob() {
4690+ let app = Skrepka;
4691+ let m = with_identity();
4692+ let vm = app.view(&m);
4693+ assert!(vm.has_identity);
4694+ assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
4695+ assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
4696+ }
4697+
4698+ /// A replayed delivery.ack with an older `ts` must be dropped, and
4699+ /// `last_ack_ts` must not regress — otherwise a relay re-delivering an old
4700+ /// page could flip messages back to "not delivered" or keep a stale ack live.
4701+ #[test]
4702+ fn a_replayed_delivery_ack_is_ignored() {
4703+ let app = Skrepka;
4704+ let mut m = with_identity();
4705+ let peer = peer_hex(9);
4706+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
4707+ contact.last_ack_ts = 100;
4708+ m.contacts.insert(peer.clone(), contact);
4709+ m.messages.insert(
4710+ peer.clone(),
4711+ vec![StoredMessage {
4712+ id: "m1".into(),
4713+ body: "hi".into(),
4714+ ts: 50,
4715+ outgoing: true,
4716+ delivered: false,
4717+ }],
4718+ );
4719+
4720+ // An ack with ts = 50 (< 100) is stale → ignored.
4721+ let ack = Payload::DeliveryAck {
4722+ ack_ids: vec!["m1".into()],
4723+ };
4724+ let page = page_from(9, &m, &ack, 50);
4725+ let _ = app.ingest_poll(&mut m, page);
4726+ assert!(
4727+ !m.messages[&peer][0].delivered,
4728+ "a stale ack must not mark the message delivered"
4729+ );
4730+ assert_eq!(
4731+ m.contacts[&peer].last_ack_ts, 100,
4732+ "last_ack_ts must not regress"
4733+ );
4734+
4735+ // An ack with ts = 200 (> 100) passes → message is marked delivered.
4736+ let ack = Payload::DeliveryAck {
4737+ ack_ids: vec!["m1".into()],
4738+ };
4739+ let page = page_from(9, &m, &ack, 200);
4740+ let _ = app.ingest_poll(&mut m, page);
4741+ assert!(
4742+ m.messages[&peer][0].delivered,
4743+ "a fresh ack must mark the message delivered"
4744+ );
4745+ assert_eq!(
4746+ m.contacts[&peer].last_ack_ts, 200,
4747+ "last_ack_ts advances to the fresh ack's ts"
4748+ );
4749+ }
4750+
4751+ // -----------------------------------------------------------------------
4752+ // cursor restoration on budget exhaustion
4753+ // -----------------------------------------------------------------------
4754+
4755+ /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4756+ /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4757+ /// must be restored to its pre-page value. Otherwise the next poll acks the
4758+ /// whole page to the relay, which deletes every event we skipped: permanent
4759+ /// message loss.
4760+ #[test]
4761+ fn a_budget_exhausted_mid_page_restores_the_cursor() {
4762+ let app = Skrepka;
4763+ let mut m = with_identity();
4764+ let peer = peer_hex(9);
4765+ m.contacts
4766+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4767+ m.cursor = 100;
4768+
4769+ // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4770+ // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4771+ // per-blob size gate; together they exceed the 64 MiB page budget.
4772+ // The first passes the budget check (21 < 64) and fails decrypt (it's
4773+ // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4774+ // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4775+ let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4776+ let page = PollResp {
4777+ events: (0..4)
4778+ .map(|_| PollEvent {
4779+ encrypted_blob: big_hex.clone(),
4780+ })
4781+ .collect(),
4782+ cursor: 200,
4783+ };
4784+ let _ = app.ingest_poll(&mut m, page);
4785+
4786+ assert_eq!(
4787+ m.cursor, 100,
4788+ "the cursor must be restored — the page was not fully processed"
4789+ );
4790+ assert!(
4791+ m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4792+ "the user is told"
4793+ );
4794+ assert!(
4795+ m.messages.get(&peer).is_none_or(Vec::is_empty),
4796+ "nothing was stored from the oversized page"
4797+ );
4798+ }
4799+
4800+ // -----------------------------------------------------------------------
4801+ // send_text block check
4802+ // -----------------------------------------------------------------------
4803+
4804+ // -----------------------------------------------------------------------
4805+ // send_text body length check
4806+ // -----------------------------------------------------------------------
4807+
4808+ /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4809+ /// enqueue a message to a blocked contact — otherwise the block only stops
4810+ /// their incoming mail while we keep sending them ours.
4811+ #[test]
4812+ fn send_text_to_a_blocked_peer_is_refused() {
4813+ let app = Skrepka;
4814+ let mut m = with_identity();
4815+ let peer = peer_hex(9);
4816+ let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4817+ contact.blocked = true;
4818+ m.contacts.insert(peer.clone(), contact);
4819+ m.active_peer = Some(peer.clone());
4820+ m.compose = "hello?".into();
4821+
4822+ let _ = app.update(Event::SendText, &mut m);
4823+
4824+ assert!(
4825+ m.outbox.is_empty(),
4826+ "no message is queued for a blocked peer"
4827+ );
4828+ assert!(
4829+ m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4830+ "the user is told why"
4831+ );
4832+ // The compose buffer is not cleared — the user might want to unblock
4833+ // and resend, and losing their draft would be surprising.
4834+ assert_eq!(m.compose, "hello?");
4835+ }
4836+
4837+ /// A text body over `MAX_BODY_LEN` would be stored locally and sent to the
4838+ /// relay, but the recipient's `parse_payload` silently drops oversized text.
4839+ /// The sender would see it stuck as "sent" forever with no error.
4840+ #[test]
4841+ fn send_text_refuses_an_oversized_body() {
4842+ let app = Skrepka;
4843+ let mut m = with_identity();
4844+ let peer = peer_hex(9);
4845+ m.active_peer = Some(peer.clone());
4846+ m.compose = "x".repeat(protocol::MAX_BODY_LEN + 1);
4847+
4848+ let _ = app.update(Event::SendText, &mut m);
4849+
4850+ assert!(m.outbox.is_empty(), "nothing is queued");
4851+ assert!(m.messages.get(&peer).is_none_or(Vec::is_empty), "nothing stored");
4852+ assert!(!m.compose.is_empty(), "compose is preserved");
4853+ assert!(m.error.as_ref().is_some_and(|e| e.contains("too long")));
4854+ }
4855+
4856+ /// A stale error from a previous failed send must be cleared on the next
4857+ /// attempt, so the error display does not persist after the user fixes the
4858+ /// issue and sends again successfully.
4859+ #[test]
4860+ fn send_text_clears_a_stale_error_on_success() {
4861+ let app = Skrepka;
4862+ let mut m = with_identity();
4863+ let peer = peer_hex(9);
4864+ m.active_peer = Some(peer.clone());
4865+ // Simulate a prior failure that left an error behind.
4866+ m.error = Some("message too long".into());
4867+
4868+ m.compose = "hello".into();
4869+ let _ = app.update(Event::SendText, &mut m);
4870+
4871+ assert!(m.error.is_none(), "a successful send clears the stale error");
4872+ assert!(!m.outbox.is_empty(), "the message was queued");
4873+ }
4874+
4875+ // -----------------------------------------------------------------------
4876+ // SaveProfile field length checks
4877+ // -----------------------------------------------------------------------
4878+
4879+ #[test]
4880+ fn save_profile_rejects_an_oversized_display_name() {
4881+ let app = Skrepka;
4882+ let mut m = with_identity();
4883+
4884+ let _ = app.update(Event::SaveProfile {
4885+ display_name: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4886+ bio: String::new(),
4887+ photo: None,
4888+ }, &mut m);
4889+
4890+ assert!(m.outbox.is_empty(), "no broadcast queued");
4891+ assert!(m.profile.display_name.is_empty(), "local profile not updated");
4892+ assert!(m.error.as_ref().is_some_and(|e| e.contains("display name")));
4893+ }
4894+
4895+ #[test]
4896+ fn save_profile_rejects_an_oversized_bio() {
4897+ let app = Skrepka;
4898+ let mut m = with_identity();
4899+
4900+ let _ = app.update(Event::SaveProfile {
4901+ display_name: "Alice".into(),
4902+ bio: "x".repeat(protocol::MAX_BIO_LEN + 1),
4903+ photo: None,
4904+ }, &mut m);
4905+
4906+ assert!(m.outbox.is_empty(), "no broadcast queued");
4907+ assert!(m.profile.bio.is_empty(), "local profile not updated");
4908+ assert!(m.error.as_ref().is_some_and(|e| e.contains("bio")));
4909+ }
4910+
4911+ #[test]
4912+ fn save_profile_rejects_an_oversized_photo() {
4913+ let app = Skrepka;
4914+ let mut m = with_identity();
4915+
4916+ let _ = app.update(Event::SaveProfile {
4917+ display_name: "Alice".into(),
4918+ bio: String::new(),
4919+ photo: Some("x".repeat(protocol::MAX_PHOTO_LEN + 1)),
4920+ }, &mut m);
4921+
4922+ assert!(m.outbox.is_empty(), "no broadcast queued");
4923+ assert!(m.profile.photo.is_none(), "local profile not updated");
4924+ assert!(m.error.as_ref().is_some_and(|e| e.contains("photo")));
4925+ }
4926+
4927+ // -----------------------------------------------------------------------
4928+ // AddContact nickname length check
4929+ // -----------------------------------------------------------------------
4930+
4931+ #[test]
4932+ fn add_contact_rejects_an_oversized_nickname() {
4933+ let app = Skrepka;
4934+ let mut m = with_identity();
4935+ let peer = peer_hex(9);
4936+
4937+ let _ = app.update(Event::AddContact {
4938+ input: peer.clone(),
4939+ nickname: "x".repeat(protocol::MAX_DISPLAY_NAME_LEN + 1),
4940+ }, &mut m);
4941+
4942+ assert!(!m.contacts.contains_key(&peer), "no contact created");
4943+ assert!(m.error.as_ref().is_some_and(|e| e.contains("nickname")));
4944+ }
4945+
4946+ // -----------------------------------------------------------------------
4947+ // FlushWatchdog unsticks a wedged send loop
4948+ // -----------------------------------------------------------------------
4949+
4950+ /// `flushing` is only cleared by `SendResult`. If the shell drops the HTTP
4951+ /// effect, the outbox is permanently stuck. The watchdog releases the guard
4952+ /// so the flush retries.
4953+ #[test]
4954+ fn the_flush_watchdog_unwedges_a_send_that_never_resolved() {
4955+ let app = Skrepka;
4956+ let mut m = with_identity();
4957+ m.token = Some("t".into());
4958+ let peer = peer_hex(9);
4959+ m.outbox.push_back(queued(&peer, "stuck"));
4960+
4961+ let _ = app.update(Event::StartFlush, &mut m);
4962+ assert!(m.flushing, "the send is in flight");
4963+ assert!(m.in_flight.is_some());
4964+
4965+ // The send never comes back. The watchdog fires, clears the stuck guard,
4966+ // and retries the flush — the message goes out again.
4967+ let mut cmd = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4968+
4969+ assert!(m.flushing, "a fresh send is in flight (the retry)");
4970+ assert!(m.in_flight.is_some(), "in_flight is set for the retry");
4971+ let batches = sent_batches(&mut cmd);
4972+ assert_eq!(batches.len(), 1, "the message is re-sent");
4973+ }
4974+
4975+ /// A watchdog that fires after the send already completed is a no-op.
4976+ #[test]
4977+ fn a_stale_flush_watchdog_leaves_a_completed_send_alone() {
4978+ let app = Skrepka;
4979+ let mut m = with_identity();
4980+ m.token = Some("t".into());
4981+ m.outbox.push_back(queued(&peer_hex(9), "hi"));
4982+
4983+ let _ = app.update(Event::StartFlush, &mut m);
4984+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
4985+ assert!(!m.flushing);
4986+ assert!(m.outbox.is_empty());
4987+
4988+ // The watchdog for the completed send fires late.
4989+ let _ = app.update(Event::FlushWatchdog(m.flush_gen), &mut m);
4990+ assert!(!m.flushing, "a completed send is not re-armed");
4991+ assert!(m.outbox.is_empty());
4992+ }
4993+
4994+ /// A late `SendResult` from a superseded send is dropped by the generation
4995+ /// check. The watchdog abandoned the first send and retried under a new
4996+ /// generation; the late result's generation no longer matches, so it cannot
4997+ /// pop the retry's items or clear its guard. The retry's own `SendResult`
4998+ /// is what eventually pops them.
4999+ #[test]
5000+ fn a_late_send_result_after_the_watchdog_is_dropped_by_generation() {
5001+ let app = Skrepka;
5002+ let mut m = with_identity();
5003+ m.token = Some("t".into());
5004+ let peer = peer_hex(9);
5005+ m.outbox.push_back(queued(&peer, "stuck"));
5006+
5007+ let _ = app.update(Event::StartFlush, &mut m);
5008+ let first_gen = m.flush_gen;
5009+ // The watchdog fires and retries the flush under a new generation.
5010+ let _ = app.update(Event::FlushWatchdog(first_gen), &mut m);
5011+ assert_eq!(m.outbox.len(), 1, "the item is still queued (re-sent)");
5012+ assert_ne!(m.flush_gen, first_gen, "the generation was bumped");
5013+ assert!(m.flushing, "the retry is in flight");
5014+
5015+ // The original send's result arrives late. Its generation no longer
5016+ // matches, so it is dropped — it cannot pop the retry's items or
5017+ // clear the retry's guard.
5018+ let _ = app.update(Event::SendResult(first_gen, Ok(ok_response(200))), &mut m);
5019+ assert!(m.flushing, "the retry's guard is untouched");
5020+ assert_eq!(m.outbox.len(), 1, "the late result popped nothing");
5021+
5022+ // The retry's own result arrives and pops the items.
5023+ let _ = app.update(Event::SendResult(m.flush_gen, Ok(ok_response(200))), &mut m);
5024+ assert!(m.outbox.is_empty(), "the retry's result pops the items");
5025+ assert!(!m.flushing, "and the guard is released");
14885026 }
14895027}