offline_protocol/protocol/storage.rs
1//! Storage persistence methods for protocol state.
2
3use super::state_crypto::{StateRecordCipher, SEALED_RECORD_OVERHEAD, STATE_RECORD_KEY_BYTES};
4use super::{
5 lifetime_expired, storage_keys, MediaTransferDescriptor, OfflineProtocol, OutboxEntry,
6 PeerCapabilities, PendingMessage, PendingMessageRecord, ReceivedKeyPackage, SessionState,
7 WelcomeDeliveryState, WelcomeLifecycleRecord, MAX_BLOCKED_USERS, MAX_KEY_PACKAGE_SENT_TO,
8 MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH, MAX_PENDING_KEY_PACKAGES, MAX_PENDING_MESSAGES_GLOBAL,
9 MAX_PENDING_MESSAGES_PER_PEER, MAX_PENDING_MESSAGE_BYTES_GLOBAL,
10 MAX_PENDING_MESSAGE_BYTES_PER_PEER, MAX_PERSISTED_CAPABILITY_VERSIONS,
11 MAX_PROTOCOL_STATE_RECORD_BYTES, MLS_ENVELOPE_COMPACT_V1, RICH_PAYLOAD_V1,
12 WELCOME_LIFECYCLE_TTL_SECS,
13};
14use crate::constants::{MAX_MEDIA_DESCRIPTORS, MAX_OUTBOX_ENTRIES};
15use crate::{Error, Event, ProtocolStateError, ProtocolStateResult, ProtocolStateStorage, Result};
16use chrono::{Duration as ChronoDuration, Utc};
17use offline_protocol_core::{LamportClock, MessageId};
18use offline_protocol_mls::{MlsManager, MlsStorage};
19use offline_protocol_transport::{NostrKeypair, NostrTransport, TransportType};
20use serde::de::DeserializeOwned;
21use std::collections::{HashMap, HashSet};
22use std::sync::{Arc, RwLock};
23use tracing::{debug, info, warn};
24use zeroize::Zeroizing;
25
26/// Every protocol-state category the SDK writes through
27/// [`OfflineProtocol::write_state_record`].
28///
29/// The enum exists for [`Self::requires_sealing`], which is an exhaustive
30/// `match`: adding a variant is a compile error until someone decides whether
31/// that category's values are sensitive. The previous spelling was a `matches!`
32/// over `&str`, which silently answers "not sensitive" for a category nobody
33/// remembered to list — and that answer means message plaintext or cloud-media
34/// key material written to the app container in the clear, which is the one
35/// thing this module exists to prevent.
36///
37/// [`Self::from_key_type`] still has to cope with an unrecognised string,
38/// because the storage API is keyed by `&str`. It answers `None`, and
39/// `write_state_record` refuses to write that: a category the sealing decision
40/// does not cover fails closed and loudly rather than open and quietly.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub(crate) enum StateCategory {
43 /// The legacy per-recipient pending layout. Read-only now — restore
44 /// migrates it into [`Self::PendingMessageEntries`] — but still sealed,
45 /// because reading it means decrypting it.
46 PendingMessages,
47 PendingMessageEntries,
48 Outbox,
49 MediaDescriptors,
50 PeerKeyPackages,
51 PeerCapabilities,
52 SessionStates,
53 WelcomeLifecycles,
54 BlockedUsers,
55 BothCreateAwaitingDecrypt,
56 LamportClock,
57 /// The Nostr receive watermark. Post-split only, like [`Self::StateAdoption`]
58 /// — deliberately absent from [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`],
59 /// which has no pre-split data to inherit for it.
60 NostrWatermark,
61 /// The Nostr key-package publication slot map. Post-split only, like
62 /// [`Self::NostrWatermark`], and absent from
63 /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] for the same reason.
64 NostrKeyPackageSlots,
65 /// The value-less marker recording that the pre-split adoption sweep
66 /// completed. Post-split only, so it is deliberately absent from
67 /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] — but it *is* written to
68 /// protocol-state storage, so it belongs to the sealing decision like
69 /// every other category.
70 StateAdoption,
71}
72
73impl StateCategory {
74 /// The category a storage key type names, or `None` for a key type the
75 /// sealing decision below does not cover.
76 pub(crate) fn from_key_type(key_type: &str) -> Option<Self> {
77 Some(match key_type {
78 storage_keys::PENDING_MESSAGES => Self::PendingMessages,
79 storage_keys::PENDING_MESSAGE_ENTRIES => Self::PendingMessageEntries,
80 storage_keys::OUTBOX => Self::Outbox,
81 storage_keys::MEDIA_DESCRIPTORS => Self::MediaDescriptors,
82 storage_keys::PEER_KEY_PACKAGES => Self::PeerKeyPackages,
83 storage_keys::PEER_CAPABILITIES => Self::PeerCapabilities,
84 storage_keys::SESSION_STATES => Self::SessionStates,
85 storage_keys::WELCOME_LIFECYCLES => Self::WelcomeLifecycles,
86 storage_keys::BLOCKED_USERS => Self::BlockedUsers,
87 storage_keys::BOTH_CREATE_AWAITING_DECRYPT => Self::BothCreateAwaitingDecrypt,
88 storage_keys::LAMPORT_CLOCK => Self::LamportClock,
89 storage_keys::NOSTR_WATERMARK => Self::NostrWatermark,
90 storage_keys::NOSTR_KEY_PACKAGE_SLOTS => Self::NostrKeyPackageSlots,
91 storage_keys::STATE_ADOPTION => Self::StateAdoption,
92 _ => return None,
93 })
94 }
95
96 /// Whether this category's values must be sealed before they reach the
97 /// install-scoped store.
98 ///
99 /// The single place record sensitivity is decided — adding a category means
100 /// deciding here, not at each call site. Sealed categories are the ones
101 /// whose values can carry message plaintext, user content, or media key
102 /// material:
103 ///
104 /// - [`storage_keys::PENDING_MESSAGE_ENTRIES`] and its legacy
105 /// per-recipient predecessor [`storage_keys::PENDING_MESSAGES`]: original
106 /// plaintext, plus rich extras that can include
107 /// `MediaMetadata::encryption_key`/`iv`.
108 /// - [`storage_keys::OUTBOX`]: the outgoing `Message` — ciphertext for
109 /// encrypted sends, but plaintext when the app opted out of encryption,
110 /// and its outer `media_metadata` carries the cloud-media secrets on the
111 /// forward path.
112 /// - [`storage_keys::MEDIA_DESCRIPTORS`]: file names and recipients of
113 /// in-flight transfers.
114 /// - [`storage_keys::PEER_KEY_PACKAGES`]: the odd one out, sealed for
115 /// **integrity** rather than confidentiality. The bytes are public wire
116 /// material and hiding them buys nothing; what matters is that a seal is
117 /// an AEAD, so a record that has been edited or moved between peers no
118 /// longer opens. Substituting a cached key package would otherwise be
119 /// enough to make this node build its session around the attacker's leaf.
120 /// `MlsManager::verify_address_binding` is the real fix and covers the
121 /// paths storage cannot see — it re-derives the address from the key
122 /// package's own signature key and refuses any package whose credential
123 /// does not match the peer id it arrived under, so a substituted package
124 /// fails wherever it is read from. This shortens the reach of a container
125 /// write as well. Failing closed is cheap here: an unsealable key package
126 /// costs one re-exchange.
127 /// - [`storage_keys::NOSTR_KEY_PACKAGE_SLOTS`]: the second integrity case,
128 /// for the same reason and not for confidentiality — slot ids and package
129 /// ids are already public in the published record itself. What sealing
130 /// buys is that the *silent* failure becomes unreachable. Deleting this
131 /// map self-heals (the next tick mints fresh slots), and pointing it at a
132 /// package id that does not exist self-heals too (a missing package reads
133 /// as consumed and is refilled). The one damaging edit is repointing a
134 /// slot at some *other* live package, which leaves a stale record standing
135 /// on the relays while this node believes the slot is healthy — and every
136 /// stranger who fetches it builds a Welcome that can never be processed.
137 /// An AEAD makes that edit unopenable, and unopenable lands on the
138 /// self-healing path.
139 ///
140 /// Everything else is advertised capability versions, a small state enum, a
141 /// logical clock, a coarse wall-clock mark, or a value-less marker whose
142 /// only information is the key id — which sealing cannot hide anyway,
143 /// because the store addresses records by it.
144 ///
145 /// [`storage_keys::NOSTR_WATERMARK`] is in that second group: it is one
146 /// unix-seconds timestamp saying roughly when this install last received a
147 /// relay event. That is strictly less than the key id already tells a
148 /// reader with container access, and the record's *integrity* buys nothing
149 /// either — the value is clamped to "not in the future" on the way back in,
150 /// and moving it backwards only widens a replay window that
151 /// `NOSTR_INITIAL_QUERY_LIMIT` already bounds.
152 ///
153 /// Note what sealing does **not** provide, for any category: it authenticates
154 /// bytes that are present, so it cannot detect a record that was deleted or
155 /// rolled back to an earlier version. Controls that must survive a deletion
156 /// cannot be built on it — see `encryption_capable_peers`.
157 fn requires_sealing(self) -> bool {
158 match self {
159 Self::PendingMessages
160 | Self::PendingMessageEntries
161 | Self::Outbox
162 | Self::MediaDescriptors
163 | Self::PeerKeyPackages
164 | Self::NostrKeyPackageSlots => true,
165 Self::PeerCapabilities
166 | Self::SessionStates
167 | Self::WelcomeLifecycles
168 | Self::BlockedUsers
169 | Self::BothCreateAwaitingDecrypt
170 | Self::LamportClock
171 | Self::NostrWatermark
172 | Self::StateAdoption => false,
173 }
174 }
175}
176
177/// Whether a key type's values must be sealed. Unknown key types answer `false`
178/// only for *reads*, where nothing this SDK wrote can be waiting — the write
179/// side refuses them outright.
180fn record_requires_sealing(key_type: &str) -> bool {
181 StateCategory::from_key_type(key_type).is_some_and(StateCategory::requires_sealing)
182}
183
184/// Outcome of reading one protocol-state record.
185///
186/// Three states, not two, because "no bytes" has three very different causes
187/// and only one of them is a loss:
188///
189/// - [`Self::Missing`] — nothing was ever here.
190/// - [`Self::Unreadable`] — a record *was* here, was examined, and is gone for
191/// good (oversized, tampered, or sealed under a key this install no longer
192/// has). It has been deleted.
193/// - [`Self::Unavailable`] — a record is still here and may be perfectly good,
194/// but cannot be read *this session* (the record key is not loaded, or the
195/// backend refused the read). It is left on disk and a later launch may
196/// recover it.
197///
198/// Restore paths that own app-visible message state settle only
199/// [`Self::Unreadable`]. Settling [`Self::Unavailable`] would be wrong twice
200/// over: the record survives, so the next launch restores it and re-drives
201/// delivery — the app would have been told the message failed terminally and
202/// then have it delivered anyway, with a hand re-send (a *new* id, so dedup
203/// cannot collapse it) landing as a second copy.
204pub(crate) enum StateRecord {
205 /// No record under this key.
206 Missing,
207 /// A record existed, could not be returned, and has been deleted — or,
208 /// when the reader was given a [`PruneBudget`] that has run out, left for a
209 /// later launch to delete. Nothing is ever recovered from it either way, so
210 /// callers treat the two identically; the budget only defers the unlink.
211 Unreadable,
212 /// A record exists but could not be read this session; it is still on disk.
213 Unavailable,
214 /// The record's plaintext value.
215 Present(Vec<u8>),
216}
217
218impl StateRecord {
219 /// The record's bytes, treating an unrecoverable record like an absent one.
220 /// For callers whose category carries no app-visible commitment.
221 fn into_bytes(self) -> Option<Vec<u8>> {
222 match self {
223 Self::Present(data) => Some(data),
224 Self::Missing | Self::Unreadable | Self::Unavailable => None,
225 }
226 }
227}
228
229/// Outcome of restoring one recipient's pending queue. Mirrors
230/// [`StateRecord`]'s three-way split for the same reason.
231enum PendingRestore {
232 /// Nothing was queued for this recipient.
233 Absent,
234 /// A queue existed but could not be recovered. Its message ids are inside
235 /// the record that would not open, so they cannot be settled individually.
236 Lost,
237 /// A queue exists but could not be read this session and is still on disk.
238 /// Reporting it as lost would be a lie a later launch contradicts.
239 Unavailable,
240 /// The recovered queue (possibly empty).
241 Restored(Vec<PendingMessage>),
242}
243
244/// Outcome of a restore-path read of one JSON record
245/// ([`OfflineProtocol::load_restorable_state_record`]). Mirrors
246/// [`StateRecord`]'s split for the same reason, collapsing only the two states
247/// a restore treats identically.
248///
249/// [`Self::Absent`] and [`Self::Unavailable`] must stay apart even though both
250/// yield no value. A caller that re-bootstraps on absence — which is exactly
251/// what `restore_session_states_from_manager` does — would otherwise persist a
252/// fresh `Pending` over a record that is still on disk and may say `Confirmed`.
253pub(crate) enum RestorableRecord<T> {
254 /// The decoded value.
255 Present(T),
256 /// Nothing was ever here, or a record was examined and dropped. Either way
257 /// the category holds no value for this key and re-bootstrapping one is
258 /// safe.
259 Absent,
260 /// A record may well be here and could not be read *this session*. It is
261 /// left on disk, so the caller must neither treat it as absent nor write
262 /// over it; a later launch can still recover it.
263 Unavailable,
264}
265
266/// Largest number of keys any single pass will process for a category with no
267/// live insert-time cap of its own.
268///
269/// The pending queue is the widest such category, at
270/// [`MAX_PENDING_MESSAGES_GLOBAL`] recipients, so a store listing more than a
271/// generous multiple of that has been tampered with or was written by a build
272/// with wildly different bounds. That threat matters more here than it did
273/// before the storage split, not less: this state used to live in the
274/// credential store and now lives in the app container, where write access is
275/// easier to come by. Sealing already keeps a forged record from ever
276/// deserializing; this bounds the *work* as well as the result.
277///
278/// Every walk stops here; what happens to the tail depends on the category:
279///
280/// - Most **restores** simply *ignore* it — they do not delete it. A later
281/// launch still reaches it, but *not* because the listing order varies: all
282/// three built-in providers return their keys sorted, so the walked prefix is
283/// deterministic. It drains because the prefix is **consumed** — restored
284/// entries are flushed, expired, or evicted for capacity, and their records
285/// deleted — so each launch lists fewer keys and the walk reaches further.
286/// The categories restore never consumes ([`storage_keys::BLOCKED_USERS`],
287/// [`storage_keys::BOTH_CREATE_AWAITING_DECRYPT`],
288/// [`storage_keys::WELCOME_LIFECYCLES`]) therefore keep the *same* tail on
289/// every launch, indefinitely. That is tolerable only because this bound sits
290/// far above anything this SDK can legitimately write, so reaching it already
291/// means the store was tampered with — do not carry the "a later launch gets
292/// the rest" argument over to a tighter bound.
293/// - The two **cache** categories (`restore_peer_key_packages`,
294/// `restore_peer_capabilities`) prune the overflow *inside* the prefix down
295/// to their own much smaller live caps. Losing a cached key package or
296/// capability record only costs a recoverable re-exchange, which is what
297/// makes deleting the overflow the right policy there. How much they prune
298/// per launch is bounded separately by [`MAX_RESTORE_PRUNE_DELETES`],
299/// because this bound limits *reads* and a prune is a synchronous provider
300/// delete — so the store shrinks by that budget per launch and the tail
301/// drains over successive launches.
302/// - **Adoption** treats hitting this bound as an incomplete pass instead: it
303/// deletes each record it moves, so withholding its completion marker drains
304/// the remainder over successive launches rather than abandoning it in the
305/// credential store (see `adopt_legacy_protocol_state`).
306///
307/// Categories whose live insert path is capped get a tighter bound derived from
308/// that cap instead (see [`OUTBOX_RESTORE_KEY_CAP`]).
309///
310/// `restore_encryption_capable_peers` uses this bound too, even though those
311/// records never leave the credential store: the argument for bounding *work* on
312/// the boot path does not depend on which store is easier to write to. It is the
313/// one category that bounds its walk without ever pruning the tail — dropping a
314/// record there re-opens the plaintext gate for that peer, so stranding the
315/// overflow is the safer failure. See the rationale there.
316pub(super) const MAX_RESTORE_KEYS_PER_CATEGORY: usize = 4 * MAX_PENDING_MESSAGES_GLOBAL;
317
318/// Restore-walk bound for the outbox.
319///
320/// The live insert path hard-caps the outbox at [`MAX_OUTBOX_ENTRIES`]
321/// (`ensure_outbox_entry` evicts before admitting), so a durable store can
322/// never legitimately hold more than that plus what a merge restored. Four
323/// times the cap is ample headroom and avoids walking — and transiently
324/// allocating up to [`MAX_PROTOCOL_STATE_RECORD_BYTES`] for — thirty times more
325/// entries than could ever be kept.
326///
327/// Media transfer descriptors deliberately keep the wider bound: unlike the
328/// outbox, `persist_media_descriptor` has no insert-time cap
329/// ([`MAX_MEDIA_DESCRIPTORS`] is applied only on restore), so a long-running
330/// session can legitimately leave more on disk than the restore cap. Walking
331/// only a tight prefix would keep the wrong ones and strand the rest forever.
332const OUTBOX_RESTORE_KEY_CAP: usize = 4 * MAX_OUTBOX_ENTRIES;
333
334/// Restore-walk bound for the pending queue, counted in *entries* rather than
335/// records.
336///
337/// [`MAX_RESTORE_KEYS_PER_CATEGORY`] bounds how many pending-queue *records*
338/// one pass opens, and each record holds a whole recipient's queue — up to
339/// [`MAX_PENDING_MESSAGES_PER_PEER`] entries after the trim. Bounding only the
340/// record count therefore admits `16384 × 64` entries, and holding the global
341/// caps across them is not free: `evict_oldest_pending_message` scans the whole
342/// in-memory queue to find the oldest entry, so every entry past
343/// [`MAX_PENDING_MESSAGES_GLOBAL`] costs an O(`MAX_PENDING_MESSAGES_GLOBAL`)
344/// pass. That is roughly four billion comparisons on the *synchronous* boot
345/// path, before counting one provider load per record.
346///
347/// This is the same lesson the built-in providers' `list_keys` bound learned:
348/// count the work, not the results. And it is reachable without tampering — the
349/// pre-split build had no pending-queue caps at all, so an upgraded install can
350/// legitimately hold far more than the caps now admit, and the adoption sweep
351/// moves all of it into the store this walk reads.
352///
353/// The tail is *ignored*, never pruned, exactly like the record-count tail
354/// above it: those ids live inside records nothing has opened, so deleting them
355/// would be an unsettleable loss. Sized at the same generous multiple of the
356/// live cap the rest of this module uses, so no store this SDK wrote is ever
357/// truncated.
358///
359/// The tail needs no freeze, and once did. Under the per-recipient layout a
360/// record held a whole queue, so an ordinary enqueue for an unwalked recipient
361/// wrote the in-memory view straight over it and the bounds' promise to leave
362/// the tail "for a later launch" had to be enforced for the whole session.
363/// Pending records are now keyed per message id, so a later write only ever
364/// touches a different key — the same reason the outbox and media-descriptor
365/// tails never needed one.
366///
367/// Both passes of the walk share this bound, and only the legacy one can still
368/// open many entries per record; for per-message records it is simply the record
369/// count.
370pub(super) const MAX_PENDING_RESTORE_ENTRIES: usize = 4 * MAX_PENDING_MESSAGES_GLOBAL;
371
372/// Durable deletes one *pool* may fund across a launch.
373///
374/// [`MAX_RESTORE_KEYS_PER_CATEGORY`] bounds how many records a walk *reads*.
375/// It does not bound how many it *deletes*, and those are not the same cost: a
376/// delete is a synchronous provider round trip that flushes the containing
377/// directory on all three built-in providers — `F_FULLFSYNC` on iOS, which is a
378/// full device barrier rather than a hint. Pruning every over-cap record in one
379/// pass therefore costs, in the worst case, tens of thousands of device
380/// barriers on the *synchronous* `initialize_mls` path: not a slow launch, a
381/// launch the platform watchdog kills.
382///
383/// So the walk bound and the prune bound are separate numbers, because they
384/// bound separate resources. The same lesson as the providers' `list_keys`
385/// bound and [`MAX_PENDING_RESTORE_ENTRIES`], applied to the write side.
386///
387/// Safe to stop early precisely because pruning is *idempotent and resumable*:
388/// an over-cap record left on disk is walked again next launch and pruned then,
389/// exactly as `adopt_legacy_protocol_state` drains its own truncated pass.
390///
391/// # The bound is on the launch, so the allowance is a pool
392///
393/// The hazard above is a property of the whole `initialize_mls` call, not of
394/// any one walk in it. An earlier spelling gave *each* walk a private
395/// allowance of this size, so six walks cost six times the bound while every
396/// one of them truthfully reported staying inside it — the same failure this
397/// constant exists to prevent, one level up. Walks therefore draw from a
398/// [`PruneAllowance`] pool, and the launch ceiling is the sum of the pools
399/// rather than the sum of the walks.
400///
401/// # Two ways to spend it, because two kinds of walk owe the application
402/// different things
403///
404/// A **cache or advisory** walk ([`PruneAllowance::refusing`]) may simply
405/// refuse a delete once its share is gone: dropping a session-state record, a
406/// cached key package, a capability record, a Welcome lifecycle, or a media
407/// descriptor costs a recoverable re-exchange or a re-bootstrap, so the record
408/// is left on disk and nothing is owed to anyone. All [`ADVISORY_PRUNE_WALKS`]
409/// of them therefore share **one** pool, created by `initialize_mls` and
410/// threaded through them, so their deletes add up against a single allowance.
411///
412/// Sharing a pool is not the same as sharing it *fairly*, and the difference
413/// matters because the walks draw in a fixed order. Each therefore leaves the
414/// walks after it a [`MIN_ADVISORY_PRUNE_DELETES`] floor rather than being free
415/// to empty the pool — without which the first walk alone could starve the rest
416/// on every launch. Being *starved* is not the same as being *deferred*: a
417/// deferred record is re-walked next launch, while a walk that never draws
418/// again defers nothing, and these prunes are the only thing that ever deletes
419/// those records. Within that floor the allowance stays elastic: a walk may
420/// take everything except what it owes the ones still to come, so a launch with
421/// clean early categories still lets a later one draw deeply.
422///
423/// A **settlement-paired** walk ([`PruneAllowance::counting`]) cannot refuse.
424/// Refusing an individual delete there would either settle an id whose record a
425/// later launch restores and re-drives — the exact contradiction
426/// [`StateRecord`] exists to prevent — or drop an entry from memory while
427/// leaving the app holding an id nothing resolves. So it *counts* what it
428/// spends, never refuses a delete mid-record, and stops at the next **record
429/// boundary**, where stopping is safe because the untouched remainder is
430/// exactly what a later launch reads.
431/// Both [`OfflineProtocol::restore_pending_messages`] and
432/// [`OfflineProtocol::restore_outbox`] spend it that way, and neither needs to
433/// freeze the tail it stops at: both categories are keyed per message id, so a
434/// later write only ever touches a different key. The pending walk did need a
435/// freeze while its records were keyed per recipient and held a whole queue —
436/// see [`MAX_PENDING_RESTORE_ENTRIES`].
437///
438/// The two settlement-paired walks get a pool **each**, rather than sharing the
439/// advisory one. Starving an advisory walk defers a cache eviction; starving
440/// the outbox walk defers *delivery* of every message the app is still holding
441/// a live id for, and starving the pending walk defers every diagnostic it
442/// owes. Neither may be held hostage to a key-package flood.
443///
444/// # The derived launch ceiling
445///
446/// Three pools, so `3 × MAX_RESTORE_PRUNE_DELETES` is the whole launch's
447/// allowance. All three are constructed side by side by `initialize_mls` — see
448/// [`PruneAllowance::pool`] for why none of them may be allocated inside the
449/// walk that spends it — and
450/// `test_one_launch_cannot_exceed_the_derived_restore_delete_ceiling` pins the
451/// total against a provider that counts deletes, which is the invariant a
452/// per-walk regression breaks and a per-pool test does not see.
453///
454/// The ceiling covers every durable delete on the path, not only the ones a
455/// walk issues while it reads. [`OfflineProtocol::restore_outbox`]'s two
456/// post-walk prunes — the absolute-lifetime drop and the capacity drain — act
457/// on entries the walk already *admitted*, so a store whose records all open
458/// cleanly reaches them with the pool untouched and its working set bounded
459/// only by [`OUTBOX_RESTORE_KEY_CAP`]. Left ungated they cost up to
460/// `OUTBOX_RESTORE_KEY_CAP - MAX_OUTBOX_ENTRIES` deletes in one launch — the
461/// ordinary over-capacity case rather than the tampered one, and the figure
462/// that walk's original budget exemption was argued from. Both are
463/// settlement-paired, so neither may refuse an individual delete; both stop
464/// *between* entries instead, and an entry the pool cannot fund is dropped from
465/// memory and left on disk **unsettled**, so a later launch owns both halves.
466/// `test_outbox_capacity_prune_stays_inside_the_launch_budget` and
467/// `test_outbox_absolute_expiry_prune_stays_inside_the_launch_budget` pin them.
468///
469/// Two caveats remain, because neither can be budgeted away.
470///
471/// A settlement-paired walk stops *between* records, so the record it is
472/// already inside may push a little past its pool.
473///
474/// And the ceiling is over the restore walks, not over every durable delete
475/// `initialize_mls` can cause. `adopt_legacy_protocol_state` runs before them
476/// and deletes from the *secure* store as it moves each record across, bounded
477/// by its own truncated-and-resumable pass rather than by this constant. It is
478/// a one-time upgrade sweep on a different provider, so it is deliberately
479/// outside the pools — but a reader deriving "the most barriers one launch can
480/// issue" should count it separately rather than reading `3 ×` as the total.
481///
482/// # This bounds deletes, and deletes are not the only durable cost
483///
484/// A *write* flushes the record **and** its directory, so it is strictly more
485/// expensive than a delete. Two write paths on the restore walk spend outside
486/// this budget on purpose:
487///
488/// - `restore_pending_messages` writes each legacy entry that survives
489/// admission under its own id before dropping the per-recipient record it
490/// came out of (`persist_migrated_pending_queues`). Those
491/// writes have their own per-launch cap,
492/// [`MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH`].
493/// - `restore_outbox` re-persists refreshed and orphaned entries. Each is
494/// paired with a settlement that cannot be separated from it, and the volume
495/// is bounded transitively by [`OUTBOX_RESTORE_KEY_CAP`].
496///
497/// Tightening the write volume means tightening those, not budgeting here.
498pub(super) const MAX_RESTORE_PRUNE_DELETES: usize = 512;
499
500/// Everything one pending-queue restore walk accumulates across recipients.
501///
502/// The running totals are maintained rather than recomputed: the global caps
503/// would otherwise re-walk the whole in-memory queue once per eviction, and the
504/// walk can admit up to [`MAX_PENDING_RESTORE_ENTRIES`] entries.
505///
506/// They start at zero rather than from the in-memory queue because that queue is
507/// necessarily empty at this point: `queue_message_for_session_establishment` is
508/// gated on `should_auto_encrypt()`, which requires an MLS manager, and
509/// `initialize_mls` returns early once one is published — so nothing can have
510/// been queued before the restore that publishes it.
511#[derive(Default)]
512struct RestoredPendingAdmission {
513 /// Entries admitted into `pending_encrypted_messages` so far.
514 global_count: usize,
515 /// Their total serialized footprint.
516 global_bytes: usize,
517 /// Ids dropped by a count or byte cap, to settle as failed and delete.
518 capacity_evicted: Vec<MessageId>,
519}
520
521/// What the two pending-restore passes accumulate before the caps are applied.
522///
523/// The passes read two different layouts and both feed one grouped view, so
524/// this exists to keep them from having to know about each other beyond the
525/// `seen` set.
526#[derive(Default)]
527struct PendingRestoreWalk {
528 /// Recovered entries, grouped by recipient but **not yet ordered** — see
529 /// [`sort_pending_queue`], which the caller applies before the trims.
530 grouped: HashMap<String, Vec<PendingMessage>>,
531 /// Ids the per-message pass has already accounted for — recovered, or
532 /// examined and settled as destroyed.
533 ///
534 /// The migration pass skips these. Recovered ones because a crash between
535 /// its writes and its delete leaves a queue present in both layouts and the
536 /// migrated copy is the one to keep; settled ones because re-filing an id
537 /// the app was just told had failed would deliver a message after its own
538 /// terminal event.
539 seen: HashSet<String>,
540 /// Ids of per-message records that were examined and destroyed. Settleable
541 /// individually, because the key is the id — *unless* a surviving legacy
542 /// record also holds the id; see `seen_in_legacy`.
543 lost_ids: Vec<String>,
544 /// Ids the migration pass skipped as `seen`, mapped to the legacy recipient
545 /// whose record holds them.
546 ///
547 /// A `lost_ids` settlement is only honest while nothing can re-file the id.
548 /// An id also inside a legacy record whose delete then fails *can* be
549 /// re-filed — the next launch recovers and sends it — so its settlement is
550 /// withheld and the next launch owns both halves.
551 seen_in_legacy: HashMap<String, String>,
552 /// Ids recovered from a legacy queue, mapped to the record they came out
553 /// of, and which therefore have no record of their own yet.
554 ///
555 /// Persisted after admission rather than during the walk, so the entries the
556 /// caps drop are never written at all — see [`OfflineProtocol::restore_pending_messages`].
557 ///
558 /// The recipient rides along because a settlement for one of these ids is
559 /// only honest once that legacy record is *gone*: while it is still on disk
560 /// it can re-file the entry on a later launch, and a terminal event the next
561 /// launch overturns is worse than no event at all.
562 migrated: HashMap<String, String>,
563 /// Legacy records whose entries have been recovered, to drop once those
564 /// entries are durable under their own ids.
565 migrated_recipients: Vec<String>,
566 /// Recipients whose whole legacy queue died inside a record that would not
567 /// open. Only reportable per peer — every id was inside it.
568 lost_recipients: Vec<String>,
569 /// Ids queued for a recipient that is no longer a valid user id.
570 unaddressable: Vec<MessageId>,
571 /// Entries opened across both passes — what [`MAX_PENDING_RESTORE_ENTRIES`]
572 /// actually bounds.
573 examined_entries: usize,
574 entry_bound_reached: bool,
575 prune_bound_reached: bool,
576 write_bound_reached: bool,
577}
578
579/// Puts a recovered queue back into canonical order: oldest first, ties broken
580/// by message id.
581///
582/// The same comparator `evict_oldest_pending_message` uses, so "index 0 is the
583/// oldest" holds for a restored queue exactly as it does for a live one — which
584/// every oldest-first trim on the restore path depends on.
585///
586/// Necessary because records are keyed per message and come back in whatever
587/// order the store enumerates them, which carries no ordering information at
588/// all. The per-recipient layout got this for free from the array it stored, and
589/// paid for it by rewriting that array on every enqueue.
590fn sort_pending_queue(messages: &mut [PendingMessage]) {
591 messages.sort_by(|left, right| {
592 left.queued_at
593 .cmp(&right.queued_at)
594 .then_with(|| left.message_id.as_str().cmp(&right.message_id.as_str()))
595 });
596}
597
598/// Advisory restore walks that draw on the shared pool, in the order
599/// `initialize_mls` runs them.
600///
601/// Keep this in step with that call site. It is the divisor behind
602/// [`MIN_ADVISORY_PRUNE_DELETES`], so adding a walk without bumping it means
603/// the last walk in the sequence has no reservation left and can be starved —
604/// the exact failure the floor exists to prevent.
605/// `test_a_flooded_advisory_category_cannot_starve_the_walks_after_it` fails if
606/// the two disagree.
607pub(super) const ADVISORY_PRUNE_WALKS: usize = 5;
608
609/// Deletes each advisory walk is guaranteed, whatever the walks before it did.
610///
611/// The shared pool alone bounds the launch but says nothing about *who* gets to
612/// spend it, and the walks draw in a fixed order. Without a floor the first one
613/// can empty the pool on its own: `restore_peer_key_packages` refuses only once
614/// the pool is gone, so a key-package store over its cap by more than
615/// [`MAX_RESTORE_PRUNE_DELETES`] — which the flood-eviction exemption makes an
616/// ordinary state, not a tampered one — leaves the four walks after it with
617/// nothing, on every launch, for as long as it stays over cap. Their prunes are
618/// the only thing that ever deletes those records, so "deferred to a later
619/// launch" would have quietly meant "never".
620///
621/// Reserving is elastic rather than a fixed split: each walk may spend
622/// everything in the pool *except* the floor owed to the walks still to come,
623/// so a launch where the early categories are clean still lets a later one draw
624/// deeply.
625///
626/// The number is deliberately well under an even share of
627/// [`MAX_RESTORE_PRUNE_DELETES`]. An even split would be the wrong trade: the
628/// category that floods is the one that needs to converge fastest, while the
629/// categories it would starve hold small counts (crash-orphaned media
630/// descriptors, records that will not decode, session states the bootstrap
631/// write repairs anyway). So the floor is set at what makes a starved walk
632/// converge in a bounded number of launches rather than at what makes the split
633/// fair, leaving the rest of the pool elastic for whichever category is
634/// actually large. With five walks that reserves 320 of 512 and leaves the
635/// first flooded walk 320 rather than 102.
636pub(super) const MIN_ADVISORY_PRUNE_DELETES: usize = 64;
637
638const _: () = assert!(
639 ADVISORY_PRUNE_WALKS * MIN_ADVISORY_PRUNE_DELETES <= MAX_RESTORE_PRUNE_DELETES,
640 "the advisory floors must fit inside one pool, or the first walk's ceiling \
641 underflows to zero and it can never prune at all"
642);
643
644/// A pool of durable restore-path deletes, drawn on by one or more walks.
645///
646/// The unit the [`MAX_RESTORE_PRUNE_DELETES`] bound is actually about. A pool
647/// outlives the walk that spends from it, which is the whole point: the five
648/// advisory walks share one pool for the length of an `initialize_mls`, so
649/// their deletes add up against a single allowance instead of each getting a
650/// private one.
651pub(crate) struct PruneAllowance {
652 remaining: usize,
653 /// Advisory walks that have not yet drawn from this pool.
654 ///
655 /// Decremented by each [`Self::refusing`] draw, and what that draw reserves
656 /// for is the walks *after* it — see [`MIN_ADVISORY_PRUNE_DELETES`]. Kept
657 /// on the pool rather than passed in per call so no walk has to know its
658 /// own position in the sequence.
659 advisory_walks_left: usize,
660}
661
662impl PruneAllowance {
663 /// One pool of [`MAX_RESTORE_PRUNE_DELETES`] deletes.
664 ///
665 /// The **only** constructor, and deliberately so. A separate one for the
666 /// settlement-paired walks let those walks allocate a pool *inside the
667 /// callee* — which is the same shape this type exists to fix, one level
668 /// down: a walk handing itself a private allowance, where a second call in
669 /// the same launch silently doubles the ceiling with nothing to show for
670 /// it. The two constructors had identical bodies, which was the tell.
671 ///
672 /// So every pool is constructed by the caller that owns the launch —
673 /// `initialize_mls` builds all three side by side — and threaded in. The
674 /// launch ceiling reads off that one call site, and
675 /// `test_one_launch_cannot_exceed_the_derived_restore_delete_ceiling` pins
676 /// it.
677 pub(crate) fn pool() -> Self {
678 Self {
679 remaining: MAX_RESTORE_PRUNE_DELETES,
680 advisory_walks_left: ADVISORY_PRUNE_WALKS,
681 }
682 }
683
684 /// A budget for a walk whose records are caches or advisory signals, which
685 /// may refuse a delete outright and leave the record for a later launch.
686 ///
687 /// Capped so the walks after this one keep their
688 /// [`MIN_ADVISORY_PRUNE_DELETES`] floor. The two settlement-paired pools
689 /// never call this, so their `advisory_walks_left` is simply never spent.
690 pub(super) fn refusing(&mut self) -> PruneBudget<'_> {
691 let later = self.advisory_walks_left.saturating_sub(1);
692 self.advisory_walks_left = later;
693 let reserved = later.saturating_mul(MIN_ADVISORY_PRUNE_DELETES);
694 let ceiling = self.remaining.saturating_sub(reserved);
695 PruneBudget::new(&mut self.remaining, ceiling, true)
696 }
697
698 /// A budget for a settlement-paired walk, which counts every delete but
699 /// never refuses one.
700 ///
701 /// Refusing mid-record would break the pairing between a delete and the
702 /// terminal event that names what it destroyed. The caller instead polls
703 /// [`PruneBudget::is_spent`] between records and stops there. Reserves
704 /// nothing: each of these owns its pool outright.
705 fn counting(&mut self) -> PruneBudget<'_> {
706 let ceiling = self.remaining;
707 PruneBudget::new(&mut self.remaining, ceiling, false)
708 }
709}
710
711/// One walk's view of a [`PruneAllowance`], and whether it ran out.
712///
713/// Deliberately has no `Default` and no free constructor: whether a walk may
714/// *refuse* a delete or only *count* it is the whole safety question, so it has
715/// to be answered at the point the budget is drawn from a pool rather than
716/// inherited from whichever answer happened to be the zero value.
717pub(super) struct PruneBudget<'a> {
718 /// Deletes the pool can still fund. Shared with every other walk drawing
719 /// on the same [`PruneAllowance`].
720 remaining: &'a mut usize,
721 /// The most *this* walk may take out of the pool, leaving the rest for the
722 /// walks after it — see [`MIN_ADVISORY_PRUNE_DELETES`].
723 ceiling: usize,
724 /// Taken from the pool so far, which is what the ceiling bounds.
725 drawn: usize,
726 /// What *this* walk spent, for its own log line. Distinct from the pool, so
727 /// one walk's report never includes another's deletes, and distinct from
728 /// [`Self::drawn`] because a counting walk keeps deleting past its ceiling.
729 pub(super) spent: usize,
730 pub(super) exhausted: bool,
731 /// Whether [`Self::claim`] may turn a delete down once this walk's share is
732 /// gone. False for settlement-paired walks — see
733 /// [`MAX_RESTORE_PRUNE_DELETES`].
734 refusable: bool,
735}
736
737impl<'a> PruneBudget<'a> {
738 fn new(remaining: &'a mut usize, ceiling: usize, refusable: bool) -> Self {
739 Self {
740 remaining,
741 ceiling,
742 drawn: 0,
743 spent: 0,
744 exhausted: false,
745 refusable,
746 }
747 }
748
749 /// Claims one delete. Refuses — and records that this walk's share is gone
750 /// — only for a refusing budget; a counting one always allows the delete
751 /// and just charges for it.
752 fn claim(&mut self) -> bool {
753 if self.is_spent() {
754 self.exhausted = true;
755 if self.refusable {
756 return false;
757 }
758 } else {
759 *self.remaining -= 1;
760 self.drawn = self.drawn.saturating_add(1);
761 }
762 self.spent = self.spent.saturating_add(1);
763 true
764 }
765
766 /// Whether this walk's share is used up — either the pool is empty or the
767 /// walk has drawn everything it may without eating a later walk's floor. A
768 /// counting caller checks this at each record boundary to decide whether to
769 /// walk any further.
770 fn is_spent(&self) -> bool {
771 *self.remaining == 0 || self.drawn >= self.ceiling
772 }
773}
774
775/// Claims one delete against an optional budget, allowing it unconditionally
776/// when the caller supplied none.
777///
778/// The `None` case is for the runtime readers, which are not on a restore walk
779/// and issue at most one delete per call. See [`MAX_RESTORE_PRUNE_DELETES`].
780fn claim_prune(budget: &mut Option<&mut PruneBudget<'_>>) -> bool {
781 match budget {
782 Some(budget) => budget.claim(),
783 None => true,
784 }
785}
786
787impl OfflineProtocol {
788 // ========================================================================
789 // PROTOCOL-STATE RECORD I/O (SEALING + SIZE POLICY CHOKEPOINT)
790 // ========================================================================
791
792 /// Writes one protocol-state record, sealing it first when its category
793 /// requires that, and refusing anything over
794 /// [`MAX_PROTOCOL_STATE_RECORD_BYTES`].
795 ///
796 /// Every value written to [`ProtocolStateStorage`] goes through here, so
797 /// confidentiality and size policy live in one place rather than being
798 /// re-decided per call site.
799 pub(crate) fn write_state_record(
800 &self,
801 storage: &dyn ProtocolStateStorage,
802 key_type: &str,
803 key_id: &str,
804 data: &[u8],
805 ) -> ProtocolStateResult<()> {
806 if data.len() > MAX_PROTOCOL_STATE_RECORD_BYTES {
807 return Err(ProtocolStateError::StoreFailed(format!(
808 "record is {} bytes, over the {} byte limit",
809 data.len(),
810 MAX_PROTOCOL_STATE_RECORD_BYTES
811 )));
812 }
813
814 // Fail closed on a category the sealing decision does not cover. The
815 // alternative is writing it in the clear because the default answer to
816 // "is this sensitive?" happened to be "no", which is exactly the
817 // silent-plaintext outcome `StateCategory` exists to make impossible.
818 let Some(category) = StateCategory::from_key_type(key_type) else {
819 return Err(ProtocolStateError::StoreFailed(format!(
820 "unknown protocol-state category '{}'; refusing to persist \
821 a record whose sensitivity has not been decided",
822 key_type
823 )));
824 };
825
826 if !category.requires_sealing() {
827 return storage.store(key_type, key_id, data);
828 }
829
830 // Fail closed: without the per-install key this record would have to be
831 // written in the clear, and losing crash recovery for it is strictly
832 // less bad than losing at-rest confidentiality.
833 let Some(cipher) = &self.state_record_cipher else {
834 return Err(ProtocolStateError::StoreFailed(
835 "protocol state record key unavailable; refusing to persist in the clear"
836 .to_string(),
837 ));
838 };
839 let Some(sealed) = cipher.seal(key_type, key_id, data) else {
840 return Err(ProtocolStateError::StoreFailed(
841 "failed to seal protocol state record".to_string(),
842 ));
843 };
844 storage.store(key_type, key_id, &sealed)
845 }
846
847 /// Reads one protocol-state record, opening it when its category is sealed,
848 /// and discarding the bytes for callers that do not need the
849 /// missing-versus-destroyed distinction.
850 pub(crate) fn read_state_record(
851 &self,
852 storage: &dyn ProtocolStateStorage,
853 key_type: &str,
854 key_id: &str,
855 ) -> ProtocolStateResult<Option<Vec<u8>>> {
856 Ok(self
857 .read_state_record_detailed(storage, key_type, key_id)?
858 .into_bytes())
859 }
860
861 /// Loads one protocol-state entry for a caller that only needs to know
862 /// whether usable bytes are there.
863 ///
864 /// Both [`ProtocolStateError::NotFound`] and
865 /// [`ProtocolStateError::Corrupted`] read as `None`. The trait documents
866 /// `NotFound` as the variant for implementations whose platform API cannot
867 /// express absence any other way, so it has to be *read* as absence here —
868 /// otherwise a provider that honors that contract turns every record it
869 /// holds into a spurious unrecoverable loss. `Corrupted` is a record that
870 /// exists and can never be decoded, which for a probe is the same answer:
871 /// there is nothing to inherit, and writing over it is the recovery.
872 ///
873 /// Callers that owe the app a settlement need the two kept apart and go
874 /// through [`Self::read_state_record_detailed`] instead.
875 fn load_state_bytes(
876 storage: &dyn ProtocolStateStorage,
877 key_type: &str,
878 key_id: &str,
879 ) -> ProtocolStateResult<Option<Vec<u8>>> {
880 match storage.load(key_type, key_id) {
881 Ok(data) => Ok(data),
882 Err(ProtocolStateError::NotFound(_) | ProtocolStateError::Corrupted(_)) => Ok(None),
883 Err(e) => Err(e),
884 }
885 }
886
887 /// Lists one protocol-state category, treating
888 /// [`ProtocolStateError::NotFound`] as an empty category.
889 ///
890 /// Same reasoning as [`Self::load_state_bytes`]: a backend that can only
891 /// spell "nothing is filed under this key type" as `NotFound` is reporting
892 /// emptiness, not failure. Every restore propagates a listing error, so
893 /// without this a provider honoring that contract fails restore and rolls
894 /// `initialize_mls` back over a store that is merely empty.
895 fn list_state_keys(
896 storage: &dyn ProtocolStateStorage,
897 key_type: &str,
898 ) -> ProtocolStateResult<Vec<String>> {
899 match storage.list_keys(key_type) {
900 Ok(keys) => Ok(keys),
901 Err(ProtocolStateError::NotFound(_)) => Ok(Vec::new()),
902 Err(e) => Err(e),
903 }
904 }
905
906 /// Reads one protocol-state record, distinguishing an absent record from
907 /// one that was destroyed and from one that merely cannot be read now.
908 ///
909 /// Never yields raw bytes for a record that is oversized or will not open.
910 /// Such a record is corrupt or tampered, so it is also deleted, which keeps
911 /// a poison record from being re-examined on every boot — and reported as
912 /// [`StateRecord::Unreadable`], because a caller holding an app-visible
913 /// promise about that record (an outbox entry the app was told was queued)
914 /// has to settle it rather than let it evaporate.
915 ///
916 /// A missing record *key* is deliberately not that: those records may be
917 /// perfectly good, so they are left on disk and reported as
918 /// [`StateRecord::Unavailable`]. Nothing is recovered from them this run
919 /// either way, but a later launch can — which is exactly why they must not
920 /// be settled as failures now.
921 ///
922 /// The same split applies to what the store itself reports.
923 /// [`ProtocolStateError::Corrupted`] is documented as a record that exists
924 /// and cannot be decoded, which is permanent by construction — so it is
925 /// [`StateRecord::Unreadable`], not a read to retry. Every other error is a
926 /// failure of *this* read and stays [`StateRecord::Unavailable`].
927 pub(crate) fn read_state_record_detailed(
928 &self,
929 storage: &dyn ProtocolStateStorage,
930 key_type: &str,
931 key_id: &str,
932 ) -> ProtocolStateResult<StateRecord> {
933 self.read_state_record_detailed_budgeted(storage, key_type, key_id, None)
934 }
935
936 /// [`Self::read_state_record_detailed`], charging its own drop-deletes to
937 /// `budget` when one is supplied.
938 ///
939 /// This reader deletes what it cannot return, and on a restore walk that is
940 /// a durable delete like any other — the same synchronous provider round
941 /// trip, with the same directory flush, that
942 /// [`MAX_RESTORE_PRUNE_DELETES`] exists to bound. A walk whose records are
943 /// *all* unreadable (a regenerated record key makes every sealed record on
944 /// the install unopenable) would otherwise issue one per record with
945 /// nothing counting them.
946 ///
947 /// A [`PruneBudget::refusing`] budget may turn the unlink down and leave the
948 /// record for a later launch; the record is reported
949 /// [`StateRecord::Unreadable`] either way, since nothing is ever recovered
950 /// from it. A [`PruneBudget::counting`] one always performs the unlink and
951 /// merely charges for it, so a settlement-paired caller
952 /// ([`Self::restore_pending_messages`]) keeps its delete and its terminal
953 /// event together and stops at its next record boundary instead.
954 ///
955 /// `None` is for the runtime callers, which are not on a restore walk and
956 /// issue at most one delete per call. **Every** restore walk passes a
957 /// budget, and the two that once did not are why that is worth stating: a
958 /// walk bound was argued to be ceiling enough for [`Self::restore_outbox`],
959 /// and a bounded-by-the-peer-count argument for
960 /// `restore_session_states_from_manager`. Neither was. See
961 /// [`MAX_RESTORE_PRUNE_DELETES`].
962 fn read_state_record_detailed_budgeted(
963 &self,
964 storage: &dyn ProtocolStateStorage,
965 key_type: &str,
966 key_id: &str,
967 mut budget: Option<&mut PruneBudget<'_>>,
968 ) -> ProtocolStateResult<StateRecord> {
969 let data = match storage.load(key_type, key_id) {
970 Ok(Some(data)) => data,
971 Ok(None) => return Ok(StateRecord::Missing),
972 // Absence, spelled as an error by a backend that cannot spell it
973 // any other way.
974 Err(ProtocolStateError::NotFound(_)) => return Ok(StateRecord::Missing),
975 // A record was there and its store cannot decode it. Nothing will
976 // ever decode it, so this is a loss to settle rather than a read to
977 // retry — and it is deleted so it cannot be re-examined on every
978 // boot. Providers that detect corruption themselves have usually
979 // deleted it already, which makes the delete a no-op.
980 Err(ProtocolStateError::Corrupted(detail)) => {
981 warn!(
982 key_type = %key_type,
983 key_id = %key_id,
984 detail = %detail,
985 "Protocol state record reported corrupt by its store; dropping"
986 );
987 if claim_prune(&mut budget) {
988 let _ = storage.delete(key_type, key_id);
989 }
990 return Ok(StateRecord::Unreadable);
991 }
992 Err(e) => return Err(e),
993 };
994
995 // Bound before deserialization: a corrupted or tampered record must not
996 // become an unbounded allocation while parsing during startup. The
997 // limit is on the *plaintext*, which is what the write side checks, so
998 // a sealed record is allowed its envelope on top — otherwise a record
999 // written at exactly the cap could never be read back.
1000 let sealed = record_requires_sealing(key_type);
1001 let limit = if sealed {
1002 MAX_PROTOCOL_STATE_RECORD_BYTES.saturating_add(SEALED_RECORD_OVERHEAD)
1003 } else {
1004 MAX_PROTOCOL_STATE_RECORD_BYTES
1005 };
1006 if data.len() > limit {
1007 warn!(
1008 key_type = %key_type,
1009 key_id = %key_id,
1010 len = data.len(),
1011 limit,
1012 "Dropping oversized protocol state record"
1013 );
1014 if claim_prune(&mut budget) {
1015 let _ = storage.delete(key_type, key_id);
1016 }
1017 return Ok(StateRecord::Unreadable);
1018 }
1019
1020 if !sealed {
1021 return Ok(StateRecord::Present(data));
1022 }
1023
1024 let Some(cipher) = &self.state_record_cipher else {
1025 // Left on disk on purpose: the record is very likely intact and the
1026 // key may be back next launch. Reporting `Unavailable` is what stops
1027 // restore from settling the whole outbox as failed and then
1028 // re-delivering it on the next boot.
1029 warn!(
1030 key_type = %key_type,
1031 key_id = %key_id,
1032 "Protocol state record key unavailable; skipping sealed record"
1033 );
1034 return Ok(StateRecord::Unavailable);
1035 };
1036
1037 match cipher.open(key_type, key_id, &data) {
1038 Some(plaintext) => Ok(StateRecord::Present(plaintext)),
1039 None => {
1040 warn!(
1041 key_type = %key_type,
1042 key_id = %key_id,
1043 sealed = StateRecordCipher::looks_sealed(&data),
1044 "Protocol state record failed to open; deleting"
1045 );
1046 if claim_prune(&mut budget) {
1047 let _ = storage.delete(key_type, key_id);
1048 }
1049 Ok(StateRecord::Unreadable)
1050 }
1051 }
1052 }
1053
1054 // ========================================================================
1055 // PRE-SPLIT PROTOCOL STATE ADOPTION
1056 // ========================================================================
1057
1058 /// Adopts protocol state written before the storage split, moving it out of
1059 /// secure storage and into the install-scoped store.
1060 ///
1061 /// Splitting the domains renamed where this state lives. Without a sweep,
1062 /// the first launch after an upgrade would come up with an empty outbox, an
1063 /// empty pending queue, and — most sharply — an **empty block list**, since
1064 /// all of it was previously persisted through the `MlsStorage` handle. The
1065 /// records would also stay in the credential store forever with nothing
1066 /// ever reading or deleting them, which is the worst possible resting place
1067 /// for `pending_messages` (message plaintext) and `outbox` (cloud-media
1068 /// `encryption_key`/`iv`) — the very values the split exists to seal.
1069 ///
1070 /// This is a bulk move rather than the read-through
1071 /// [`crate::MlsStorage`] adoption used for MLS material, because
1072 /// [`storage_keys::ADOPTABLE_STATE_KEY_TYPES`] is a *closed* set: the SDK
1073 /// declares every protocol-state category itself, where OpenMLS contributes
1074 /// arbitrary labels to the MLS keyspace. Enumeration therefore terminates,
1075 /// which read-through can never guarantee.
1076 ///
1077 /// **This sweep depends on that other adoption.** On a pre-upgrade install
1078 /// the records it is looking for sit in the *un-namespaced* credential
1079 /// store, and the handle it enumerates is the namespaced one — so the only
1080 /// reason `secure.list_keys` and `secure.load` reach them at all is that
1081 /// the built-in providers union in, and read through to, the legacy store
1082 /// (`MlsSecureStorage` / `SecureStorage`, see `LegacyStoreAdoption`). Two
1083 /// independently-designed mechanisms, one silently load-bearing for the
1084 /// other: a provider built without a namespace has no read-through, so this
1085 /// sweep finds nothing and the pre-split state stays where it is. Python
1086 /// warns about exactly that case at construction.
1087 ///
1088 /// Properties worth keeping if this is ever touched:
1089 ///
1090 /// - **Resumable.** Each record is deleted from secure storage only once it
1091 /// is durably written to protocol-state storage, so a crash mid-sweep
1092 /// leaves the remainder to be re-adopted next launch.
1093 /// - **Non-destructive.** A key already present in protocol-state storage
1094 /// wins and its legacy twin is left alone. Post-split state is always
1095 /// authoritative, and — since one backend may legitimately serve both
1096 /// handles in tests — a blind copy-then-delete could otherwise delete a
1097 /// record through the same store it just read it from.
1098 /// - **All-or-nothing marker.** The marker is written only when the sweep
1099 /// completed without a storage error, so a transiently unavailable
1100 /// credential store means "try again next launch", not "give up forever".
1101 /// - **Sealing applies.** Records are written through
1102 /// [`Self::write_state_record`], so categories that require sealing are
1103 /// sealed on the way in and the legacy plaintext is then deleted.
1104 /// - **Nothing app-visible vanishes quietly.** A legacy record too large to
1105 /// have any reachable destination is settled before it is deleted, the
1106 /// same way [`Self::restore_outbox`] settles one it cannot recover. This
1107 /// is not hypothetical: the pre-split build had neither a content cap nor
1108 /// a per-peer byte budget, only 64 entries per peer, so the installs this
1109 /// branch's budgets exist for are exactly the ones whose legacy records
1110 /// can exceed [`MAX_PROTOCOL_STATE_RECORD_BYTES`].
1111 pub(crate) fn adopt_legacy_protocol_state(&mut self) {
1112 let (Some(secure), Some(state)) = (
1113 self.secure_storage.clone(),
1114 self.protocol_state_storage.clone(),
1115 ) else {
1116 return;
1117 };
1118
1119 match Self::load_state_bytes(
1120 state.as_ref(),
1121 storage_keys::STATE_ADOPTION,
1122 storage_keys::STATE_ADOPTION_ID,
1123 ) {
1124 Ok(Some(_)) => return,
1125 Ok(None) => {}
1126 Err(e) => {
1127 // Unknown whether the sweep already ran. Skipping is the safe
1128 // side: adoption is non-destructive, so re-running it later
1129 // costs nothing, while running it against a store we cannot
1130 // read is unnecessary work on every boot.
1131 warn!(error = %e, "Could not read the protocol-state adoption marker; skipping adoption");
1132 return;
1133 }
1134 }
1135
1136 let mut adopted = 0usize;
1137 let mut failed = false;
1138 // Settlements for records the sweep had to destroy. Collected rather
1139 // than emitted inline because the per-record helper only holds `&self`,
1140 // and because they belong on the same deferred path every other restore
1141 // settlement uses.
1142 let mut unadoptable: Vec<Event> = Vec::new();
1143 for key_type in storage_keys::ADOPTABLE_STATE_KEY_TYPES {
1144 let key_ids = match secure.list_keys(key_type) {
1145 Ok(ids) => ids,
1146 Err(e) => {
1147 warn!(key_type = %key_type, error = %e, "Failed to list legacy protocol state");
1148 failed = true;
1149 continue;
1150 }
1151 };
1152
1153 if key_ids.len() > MAX_RESTORE_KEYS_PER_CATEGORY {
1154 // Never reachable from state this SDK wrote, so bound the pass
1155 // — but a truncated pass is not a completed one. Withholding
1156 // the marker is what makes the tail drain over successive
1157 // launches (each one adopts and deletes a prefix) instead of
1158 // being abandoned in the credential store forever, which for
1159 // `pending_messages` and `outbox` means message plaintext and
1160 // cloud-media key material parked in the one place this sweep
1161 // exists to clear.
1162 warn!(
1163 key_type = %key_type,
1164 listed = key_ids.len(),
1165 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
1166 "Legacy protocol state listed more entries than any legitimate run can produce; adopting a prefix and retrying the rest next launch"
1167 );
1168 failed = true;
1169 }
1170 for key_id in key_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
1171 match self.adopt_one_legacy_record(
1172 secure.as_ref(),
1173 state.as_ref(),
1174 key_type,
1175 &key_id,
1176 &mut unadoptable,
1177 ) {
1178 Ok(true) => adopted += 1,
1179 Ok(false) => {}
1180 Err(()) => failed = true,
1181 }
1182 }
1183 }
1184
1185 // Settle before the early return below: these records are already gone,
1186 // so a partial sweep must not also withhold the only signal the app will
1187 // ever get about them.
1188 for event in unadoptable {
1189 self.settle_restored_message_failure(event);
1190 }
1191
1192 if failed {
1193 warn!(
1194 adopted,
1195 "Pre-split protocol state only partially adopted; will retry on the next launch"
1196 );
1197 return;
1198 }
1199
1200 if let Err(e) = self.write_state_record(
1201 state.as_ref(),
1202 storage_keys::STATE_ADOPTION,
1203 storage_keys::STATE_ADOPTION_ID,
1204 &[],
1205 ) {
1206 // Without the marker the sweep simply runs again next launch. It is
1207 // idempotent, so that costs one wasted enumeration, not correctness.
1208 warn!(error = %e, "Failed to record the protocol-state adoption marker");
1209 }
1210 if adopted > 0 {
1211 info!(
1212 count = adopted,
1213 "Adopted pre-split protocol state from secure storage"
1214 );
1215 }
1216 }
1217
1218 /// Moves one legacy record. `Ok(true)` when it was adopted, `Ok(false)`
1219 /// when there was nothing to do, `Err(())` when a storage failure means the
1220 /// sweep must be retried later. Settlements for records this had to destroy
1221 /// are pushed onto `unadoptable` for the caller to emit.
1222 fn adopt_one_legacy_record(
1223 &self,
1224 secure: &dyn MlsStorage,
1225 state: &dyn ProtocolStateStorage,
1226 key_type: &str,
1227 key_id: &str,
1228 unadoptable: &mut Vec<Event>,
1229 ) -> std::result::Result<bool, ()> {
1230 // Post-split state wins: never overwrite a record this build wrote.
1231 //
1232 // The probe gets the same three-way treatment as every other read, for
1233 // the same reason. A destination the store itself destroyed is not
1234 // absent — the app holds ids for it — so the sweep proceeds but owes a
1235 // settlement, which the legacy record only discharges by replacing it.
1236 // A destination that is merely unreadable *this session* is left alone
1237 // entirely: adopting over it would overwrite a record a later launch
1238 // can still read.
1239 let destination_destroyed = match self.read_state_record_detailed(state, key_type, key_id) {
1240 Ok(StateRecord::Present(_)) => return Ok(false),
1241 Ok(StateRecord::Missing) => false,
1242 Ok(StateRecord::Unreadable) => true,
1243 Ok(StateRecord::Unavailable) => {
1244 warn!(
1245 key_type = %key_type,
1246 "Protocol state record unreadable this session; deferring adoption of its legacy twin"
1247 );
1248 return Err(());
1249 }
1250 Err(e) => {
1251 warn!(key_type = %key_type, error = %e, "Failed to probe protocol state during adoption");
1252 return Err(());
1253 }
1254 };
1255
1256 let data = match secure.load(key_type, key_id) {
1257 Ok(Some(data)) => data,
1258 // A key that listed but no longer loads was deleted underneath us.
1259 Ok(None) => {
1260 // Nothing left to inherit *and* the destination was destroyed
1261 // by the probe, so this is the last moment anything can name
1262 // what the app is still holding.
1263 if destination_destroyed {
1264 unadoptable.extend(Self::unadoptable_record_settlement(key_type, key_id));
1265 }
1266 return Ok(false);
1267 }
1268 Err(e) => {
1269 warn!(key_type = %key_type, error = %e, "Failed to read legacy protocol state");
1270 return Err(());
1271 }
1272 };
1273
1274 if data.len() > MAX_PROTOCOL_STATE_RECORD_BYTES {
1275 // Over the record cap, so it could never be written or restored.
1276 // Delete it rather than retrying the sweep forever over a record
1277 // that has no reachable destination — but settle first: the app is
1278 // holding ids from before the upgrade, and this is the last moment
1279 // anything can name them.
1280 warn!(
1281 key_type = %key_type,
1282 len = data.len(),
1283 "Dropping oversized legacy protocol state record"
1284 );
1285 unadoptable.extend(Self::unadoptable_record_settlement(key_type, key_id));
1286 let _ = secure.delete(key_type, key_id);
1287 return Ok(false);
1288 }
1289
1290 if let Err(e) = self.write_state_record(state, key_type, key_id, &data) {
1291 warn!(key_type = %key_type, error = %e, "Failed to adopt legacy protocol state record");
1292 return Err(());
1293 }
1294
1295 // Only now is the legacy copy redundant. Ordering matters: a delete
1296 // before the write would lose the record on a crash in between.
1297 if let Err(e) = secure.delete(key_type, key_id) {
1298 // The record is safely adopted; the leftover is cosmetic and the
1299 // next sweep's "already present" check will skip it. Do not fail
1300 // the sweep over it.
1301 debug!(key_type = %key_type, error = %e, "Failed to delete adopted legacy protocol state record");
1302 }
1303 Ok(true)
1304 }
1305
1306 /// The settlement owed for a legacy record that had to be destroyed rather
1307 /// than adopted, or `None` for a category the app holds no promise about.
1308 ///
1309 /// Mirrors what [`Self::restore_outbox`] and [`Self::restore_pending_messages`]
1310 /// emit for the same loss after the split: an outbox record is nameable
1311 /// without opening it, because its record key *is* the message id, while a
1312 /// pending queue's ids are inside the record — so the recipient is the most
1313 /// that can be reported.
1314 ///
1315 /// An outbox key that does not parse as a [`MessageId`] falls back to the
1316 /// same diagnostic the pending queue uses rather than to silence. It should
1317 /// not exist — this SDK has only ever keyed the outbox by message id — but
1318 /// "should not exist" is exactly the class of record this whole path is for,
1319 /// and the sweep deletes it either way. A settlement nobody can act on still
1320 /// beats a destruction nobody is told about, which is the invariant the rest
1321 /// of this module spends its time enforcing.
1322 fn unadoptable_record_settlement(key_type: &str, key_id: &str) -> Option<Event> {
1323 match key_type {
1324 storage_keys::OUTBOX => Some(Self::unrecoverable_message_settlement(
1325 key_id,
1326 "Outbox entry from a previous version was too large to migrate",
1327 0,
1328 )),
1329 storage_keys::PENDING_MESSAGES => Some(Event::convergence_diag(
1330 "pending_state_lost".to_string(),
1331 key_id.to_string(),
1332 "Messages queued before the storage split exceeded the protocol-state record \
1333 limit and could not be migrated"
1334 .to_string(),
1335 )),
1336 _ => None,
1337 }
1338 }
1339
1340 /// The settlement owed for a message-keyed record that has been destroyed
1341 /// and cannot be recovered — an outbox entry or a pending-queue entry.
1342 ///
1343 /// The record key *is* the message id, so the loss is nameable without
1344 /// opening the record — but only if the key parses. A key that does not is
1345 /// reported as a `pending_state_lost` diagnostic carrying the raw key,
1346 /// rather than as silence. It should not exist (this SDK keys both
1347 /// categories by message id), but "should not exist" is exactly the class of
1348 /// record every caller of this is handling, and the record is deleted either
1349 /// way. A settlement nobody can act on still beats a destruction nobody is
1350 /// told about.
1351 fn unrecoverable_message_settlement(key_id: &str, reason: &str, attempts: u32) -> Event {
1352 MessageId::from_str(key_id).map_or_else(
1353 |_| {
1354 Event::convergence_diag(
1355 "pending_state_lost".to_string(),
1356 key_id.to_string(),
1357 format!(
1358 "{reason}, and its message id could not be recovered from the record key"
1359 ),
1360 )
1361 },
1362 |message_id| Event::message_failed(message_id, reason.to_string(), attempts),
1363 )
1364 }
1365
1366 // ========================================================================
1367 // PROTOCOL-STATE RECORD KEY
1368 // ========================================================================
1369
1370 /// Loads (or, on first run, generates and persists) the per-install key that
1371 /// seals sensitive protocol-state records.
1372 ///
1373 /// The key lives in *secure* storage while the records it protects live in
1374 /// the install-scoped container: uninstalling the app drops the container,
1375 /// and a container lifted without the credential store yields only
1376 /// ciphertext.
1377 ///
1378 /// Unlike the scrub and Nostr secrets, this one does **not** degrade to a
1379 /// session-local value when it cannot be persisted. A key that does not
1380 /// survive the process would seal records nothing could ever open, so a
1381 /// persist failure leaves the cipher uninstalled and sensitive categories
1382 /// simply are not persisted this session (they stay in memory and are
1383 /// re-driven from there, exactly as when no storage is configured at all).
1384 ///
1385 /// Unlike the other secret-restore paths this one has no "already loaded"
1386 /// short circuit, so the installed cipher is always the one belonging to
1387 /// the secure storage currently attached. Reusing a cipher across a storage
1388 /// swap would seal records under a key the next launch cannot find, which
1389 /// reads as silent loss of every sealed record.
1390 ///
1391 /// That invariant is enforced, not just documented: any previously
1392 /// installed cipher is dropped *before* the new store is read, so every
1393 /// path out of here either installs the key belonging to the currently
1394 /// attached store or leaves none at all. A failed load must not fall back
1395 /// to the key of a store we are no longer using — that cipher would open
1396 /// nothing here (records written under the new store's key are deleted as
1397 /// unauthentic on read) and would seal records the next launch cannot
1398 /// find a key for.
1399 ///
1400 /// # A present-but-wrong-length key is the destructive case
1401 ///
1402 /// The two failure branches below are deliberately asymmetric, and it is
1403 /// worth being explicit about why, because the asymmetry looks like an
1404 /// oversight and is not.
1405 ///
1406 /// A *failed load* leaves the cipher uninstalled. Sealed records then read
1407 /// as [`StateRecord::Unavailable`]: they stay on disk, nothing is settled,
1408 /// and a launch that can read the key recovers them. That is the recoverable
1409 /// case, and it is treated as one.
1410 ///
1411 /// A blob of the *wrong length* is not the key and never will be — no
1412 /// process can recover the original from it — so everything sealed under
1413 /// that key is already unrecoverable by the time this function runs.
1414 /// Regenerating is therefore not what destroys those records; it is what
1415 /// lets the install seal again. The visible consequence is still large: the
1416 /// records fail to open on the next read, are reported
1417 /// [`StateRecord::Unreadable`], and restore settles the whole outbox and
1418 /// pending queue as terminal `message_failed`. That is the honest answer —
1419 /// the alternative, refusing to regenerate, would preserve ciphertext
1420 /// nobody can ever open while permanently disabling persistence for every
1421 /// sensitive category.
1422 ///
1423 /// What it must not do is look like routine key generation in the log, so
1424 /// the warning names the consequence rather than the symptom.
1425 pub(crate) fn restore_or_init_state_record_key(&mut self) {
1426 self.state_record_cipher = None;
1427
1428 let Some(storage) = &self.secure_storage else {
1429 return;
1430 };
1431
1432 let key: Zeroizing<[u8; STATE_RECORD_KEY_BYTES]> = match storage.load(
1433 storage_keys::STATE_RECORD_KEY,
1434 storage_keys::STATE_RECORD_KEY_ID,
1435 ) {
1436 Ok(Some(bytes)) if bytes.len() == STATE_RECORD_KEY_BYTES => {
1437 let bytes = Zeroizing::new(bytes);
1438 let mut key = Zeroizing::new([0u8; STATE_RECORD_KEY_BYTES]);
1439 key.copy_from_slice(&bytes);
1440 debug!("Restored protocol state record key from secure storage");
1441 key
1442 }
1443 Ok(other) => {
1444 // A wrong-length blob is a corrupt write, not a usable key, and
1445 // nothing can recover the original from it — so whatever it
1446 // sealed is already lost before this runs. Regenerating is what
1447 // lets the install seal again; see the note on this function
1448 // for why refusing to is worse.
1449 if let Some(bytes) = &other {
1450 warn!(
1451 len = bytes.len(),
1452 expected = STATE_RECORD_KEY_BYTES,
1453 "Protocol state record key is not a key and cannot be recovered; \
1454 regenerating. Every record sealed under the old key is \
1455 unrecoverable and will be settled as failed on restore — this is \
1456 not routine key generation"
1457 );
1458 }
1459 let fresh = StateRecordCipher::generate_key();
1460 if let Err(e) = storage.store(
1461 storage_keys::STATE_RECORD_KEY,
1462 storage_keys::STATE_RECORD_KEY_ID,
1463 &*fresh,
1464 ) {
1465 warn!(
1466 error = %e,
1467 "Failed to persist protocol state record key; \
1468 sensitive protocol state will not be persisted this session"
1469 );
1470 return;
1471 }
1472 info!("Generated and persisted per-install protocol state record key");
1473 fresh
1474 }
1475 Err(e) => {
1476 warn!(
1477 error = %e,
1478 "Failed to load protocol state record key; \
1479 sensitive protocol state will not be persisted this session"
1480 );
1481 return;
1482 }
1483 };
1484
1485 self.state_record_cipher = Some(StateRecordCipher::new(&key));
1486 }
1487
1488 // ========================================================================
1489 // PENDING MESSAGES PERSISTENCE
1490 // ========================================================================
1491
1492 /// Persists one queued message, keyed by its own id, returning whether the
1493 /// record is durably on disk.
1494 ///
1495 /// Best-effort on the send path, like [`Self::persist_outbox_entry`]: it
1496 /// cannot propagate storage errors, so a failed write is logged and the
1497 /// return ignored. The message still lives in the in-memory queue and still
1498 /// flushes when the session establishes; it just will not survive a
1499 /// restart. The migration path must honour the return instead — its next
1500 /// step is deleting the legacy record these entries came out of, and doing
1501 /// that on top of a failed write would destroy the only surviving copy (see
1502 /// [`Self::persist_migrated_pending_queues`]).
1503 ///
1504 /// Re-persisting an id already on disk is an ordinary overwrite of its own
1505 /// record, which is what makes the flush-time re-queue path idempotent.
1506 pub(crate) fn persist_pending_message(
1507 &self,
1508 recipient: &str,
1509 message: &PendingMessage,
1510 ) -> bool {
1511 let Some(storage) = &self.protocol_state_storage else {
1512 return false;
1513 };
1514 let record = PendingMessageRecord {
1515 recipient: recipient.to_string(),
1516 message: message.clone(),
1517 };
1518 match serde_json::to_vec(&record) {
1519 Ok(data) => {
1520 if let Err(e) = self.write_state_record(
1521 storage.as_ref(),
1522 storage_keys::PENDING_MESSAGE_ENTRIES,
1523 &record.message.message_id.as_str(),
1524 &data,
1525 ) {
1526 warn!(
1527 recipient = %recipient,
1528 message_id = %record.message.message_id,
1529 error = %e,
1530 "Failed to persist pending message"
1531 );
1532 return false;
1533 }
1534 true
1535 }
1536 Err(e) => {
1537 warn!(
1538 recipient = %recipient,
1539 message_id = %record.message.message_id,
1540 error = %e,
1541 "Failed to serialize pending message"
1542 );
1543 false
1544 }
1545 }
1546 }
1547
1548 /// Removes one persisted pending message.
1549 ///
1550 /// Best-effort and logged rather than swallowed, for the reason the old
1551 /// whole-queue clear was: a delete that silently failed leaves a record the
1552 /// next launch restores and re-flushes. The message carries its original id
1553 /// so receivers dedup it, but the sender re-emits `MessageSent` for traffic
1554 /// it already delivered, and nothing would say why.
1555 pub(crate) fn delete_pending_message_from_storage(&self, message_id: &MessageId) {
1556 let Some(storage) = &self.protocol_state_storage else {
1557 return;
1558 };
1559 if let Err(e) = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &message_id.as_str())
1560 {
1561 warn!(
1562 message_id = %message_id,
1563 error = %e,
1564 "Failed to clear persisted pending message"
1565 );
1566 }
1567 }
1568
1569 /// Removes the persisted copies of a batch of queued messages.
1570 pub(crate) fn delete_pending_messages_from_storage<'a>(
1571 &self,
1572 message_ids: impl IntoIterator<Item = &'a MessageId>,
1573 ) {
1574 for message_id in message_ids {
1575 self.delete_pending_message_from_storage(message_id);
1576 }
1577 }
1578
1579 /// Reassembles one recipient's persisted queue from the per-message
1580 /// records, in canonical queue order.
1581 ///
1582 /// Test-only, and deliberately the expensive spelling — it lists the whole
1583 /// category and filters. Production restore reads every record once and
1584 /// groups them in a single pass ([`Self::restore_pending_messages`]);
1585 /// nothing on the runtime path ever needs one peer's queue *from storage*,
1586 /// because the in-memory map is the source of truth.
1587 #[cfg(test)]
1588 pub(crate) fn load_pending_messages_from_storage(
1589 &self,
1590 recipient: &str,
1591 ) -> Option<Vec<PendingMessage>> {
1592 let storage = self.protocol_state_storage.as_ref()?;
1593 let keys =
1594 Self::list_state_keys(storage.as_ref(), storage_keys::PENDING_MESSAGE_ENTRIES).ok()?;
1595 let mut messages: Vec<PendingMessage> = keys
1596 .iter()
1597 .filter_map(|key| {
1598 let data = self
1599 .read_state_record_detailed(
1600 storage.as_ref(),
1601 storage_keys::PENDING_MESSAGE_ENTRIES,
1602 key,
1603 )
1604 .ok()?
1605 .into_bytes()?;
1606 let record = serde_json::from_slice::<PendingMessageRecord>(&data).ok()?;
1607 (record.recipient == recipient).then_some(record.message)
1608 })
1609 .collect();
1610 if messages.is_empty() {
1611 return None;
1612 }
1613 sort_pending_queue(&mut messages);
1614 for message in &mut messages {
1615 message.measure();
1616 }
1617 Some(messages)
1618 }
1619
1620 /// Loads a recipient's queue from the **legacy** per-recipient record,
1621 /// distinguishing "nothing queued" from "a queue existed and its contents
1622 /// are gone" from "a queue exists and cannot be read this session".
1623 ///
1624 /// Entries come back *unmeasured*: the derived `serialized_bytes` is
1625 /// deliberately not persisted, and measuring re-serializes every entry, so
1626 /// the caller measures only what it decides to keep rather than paying for
1627 /// entries the per-peer count trim is about to drop. Anything that enters
1628 /// `pending_encrypted_messages` must be measured first or it reads as free
1629 /// against the queue byte budgets.
1630 ///
1631 /// `budget` counts the durable deletes this read causes — the reader's own
1632 /// drop of a record that will not open, and the drop below of one whose
1633 /// bytes will not parse. It must be a [`PruneBudget::counting`] budget, so
1634 /// neither delete is ever refused: both are paired with the
1635 /// `pending_state_lost` the caller emits, and settling a record still
1636 /// sitting on disk is a claim the next launch contradicts. The caller stops
1637 /// walking once the budget reads spent instead.
1638 fn load_legacy_pending_queue(
1639 &self,
1640 recipient: &str,
1641 mut budget: Option<&mut PruneBudget<'_>>,
1642 ) -> PendingRestore {
1643 let Some(storage) = self.protocol_state_storage.as_ref() else {
1644 return PendingRestore::Absent;
1645 };
1646 let record = self.read_state_record_detailed_budgeted(
1647 storage.as_ref(),
1648 storage_keys::PENDING_MESSAGES,
1649 recipient,
1650 budget.as_deref_mut(),
1651 );
1652 let data = match record {
1653 Ok(StateRecord::Present(data)) => data,
1654 Ok(StateRecord::Missing) => return PendingRestore::Absent,
1655 // Examined and destroyed: the queue that existed is gone for good.
1656 Ok(StateRecord::Unreadable) => return PendingRestore::Lost,
1657 // Still on disk, just not readable now. Reporting it as lost would
1658 // be contradicted by the next launch restoring it.
1659 Ok(StateRecord::Unavailable) | Err(_) => return PendingRestore::Unavailable,
1660 };
1661 let Ok(messages) = serde_json::from_slice::<Vec<PendingMessage>>(&data) else {
1662 // Parsed-as-garbage is the same loss as failed-to-open. Drop the
1663 // record so it is not re-parsed on every boot.
1664 warn!(recipient = %recipient, "Dropping corrupted pending message record");
1665 // Charged, and the answer deliberately not honoured: this delete is
1666 // paired with the caller's `pending_state_lost`, so a budget that
1667 // refused it would leave a settled record on disk. A `counting`
1668 // budget never refuses; ignoring the answer is what keeps a
1669 // mistakenly-`refusing` one failing in the safe direction.
1670 claim_prune(&mut budget);
1671 let _ = storage.delete(storage_keys::PENDING_MESSAGES, recipient);
1672 return PendingRestore::Lost;
1673 };
1674 PendingRestore::Restored(messages)
1675 }
1676
1677 /// Drops a legacy per-recipient record once its entries have been migrated
1678 /// into the per-message layout, or once they have been settled.
1679 ///
1680 /// Only ever called with a record this session has already *read*, which is
1681 /// what makes it safe without the freeze the old whole-queue clear needed:
1682 /// nothing outside the migration walk touches this category any more, so a
1683 /// record no walk reached is simply read by a later launch. (The one other
1684 /// writer, `adopt_legacy_protocol_state`, moves pre-split records *into*
1685 /// this category — but it runs earlier in the same launch, before this
1686 /// walk, so it is not an exception to the conclusion.)
1687 ///
1688 /// Returns whether the record is gone. Nothing else ever writes this
1689 /// category during or after the walk, so a delete that failed is permanent
1690 /// for the session — the caller has to undo the migration rather than log
1691 /// and move on, or the surviving record re-files its whole queue on a later
1692 /// launch. See [`Self::forget_migrated_entries`].
1693 fn delete_legacy_pending_queue(&self, recipient: &str) -> bool {
1694 if let Some(storage) = &self.protocol_state_storage {
1695 if let Err(e) = storage.delete(storage_keys::PENDING_MESSAGES, recipient) {
1696 warn!(
1697 recipient = %recipient,
1698 error = %e,
1699 "Failed to clear migrated legacy pending queue"
1700 );
1701 return false;
1702 }
1703 }
1704 true
1705 }
1706
1707 /// Admits one recipient's recovered queue, holding it to the same caps the
1708 /// live admission path enforces.
1709 ///
1710 /// Split out of [`Self::restore_pending_messages`] because it is the one
1711 /// part of that walk with its own invariants rather than its own control
1712 /// flow: a record written by an older build — or by a build with no caps at
1713 /// all, which is every pre-split build — can hold entries the current
1714 /// boundary would reject, so restore has to re-apply four bounds in a fixed
1715 /// order.
1716 ///
1717 /// The order is load-bearing:
1718 ///
1719 /// 1. **Count trim first**, so the measure below is not paid for entries
1720 /// that are about to be dropped anyway.
1721 /// 2. **Measure the survivors.** `serialized_bytes` is derived and not
1722 /// persisted, and everything after this point — both byte budgets and the
1723 /// map insert — reads an unmeasured entry as free.
1724 /// 3. **Per-peer byte trim**, oldest-first like every other eviction here.
1725 /// 4. **Global count and byte trim**, which can evict from a recipient
1726 /// admitted on an earlier iteration, hence the shared accumulator.
1727 ///
1728 /// Every entry this drops goes into `capacity_evicted`, and the caller both
1729 /// settles those ids and deletes their records. Under the per-recipient
1730 /// layout the trim also had to *rewrite* the survivors' record, so it
1731 /// tracked which recipients had changed; now a dropped entry is just its own
1732 /// record, and the survivors' records are already correct.
1733 fn admit_restored_pending_queue(
1734 &mut self,
1735 recipient: &str,
1736 mut messages: Vec<PendingMessage>,
1737 admission: &mut RestoredPendingAdmission,
1738 ) {
1739 let overflow = messages.len().saturating_sub(MAX_PENDING_MESSAGES_PER_PEER);
1740 if overflow > 0 {
1741 admission
1742 .capacity_evicted
1743 .extend(messages.drain(..overflow).map(|message| message.message_id));
1744 }
1745 for message in &mut messages {
1746 message.measure();
1747 }
1748
1749 let mut peer_bytes: usize = messages
1750 .iter()
1751 .map(|message| message.serialized_bytes)
1752 .sum();
1753 let mut dropped_for_bytes = 0usize;
1754 while peer_bytes > MAX_PENDING_MESSAGE_BYTES_PER_PEER && !messages.is_empty() {
1755 let message = messages.remove(0);
1756 peer_bytes = peer_bytes.saturating_sub(message.serialized_bytes);
1757 admission.capacity_evicted.push(message.message_id);
1758 dropped_for_bytes += 1;
1759 }
1760 if dropped_for_bytes > 0 {
1761 warn!(
1762 recipient = %recipient,
1763 dropped = dropped_for_bytes,
1764 limit = MAX_PENDING_MESSAGE_BYTES_PER_PEER,
1765 "Restored pending queue exceeded the per-peer byte budget"
1766 );
1767 }
1768
1769 if !messages.is_empty() {
1770 info!(recipient = %recipient, count = messages.len(), "Restored pending messages from storage");
1771 admission.global_count += messages.len();
1772 admission.global_bytes += peer_bytes;
1773 self.pending_encrypted_messages
1774 .insert(recipient.to_string(), messages);
1775 }
1776
1777 while admission.global_count > MAX_PENDING_MESSAGES_GLOBAL
1778 || admission.global_bytes > MAX_PENDING_MESSAGE_BYTES_GLOBAL
1779 {
1780 let Some((_, message)) = self.evict_oldest_pending_message() else {
1781 break;
1782 };
1783 admission.global_count = admission.global_count.saturating_sub(1);
1784 admission.global_bytes = admission
1785 .global_bytes
1786 .saturating_sub(message.serialized_bytes);
1787 admission.capacity_evicted.push(message.message_id);
1788 }
1789 }
1790
1791 /// Restores all pending messages from storage on startup.
1792 ///
1793 /// Two passes over two layouts:
1794 ///
1795 /// 1. **Per-message records** ([`storage_keys::PENDING_MESSAGE_ENTRIES`]),
1796 /// which is what this SDK writes. Each is one queued message keyed by its
1797 /// own id, so a record that will not open still *names* the message it
1798 /// destroyed and is settled with a `message_failed` for exactly that id —
1799 /// the reason the layout exists.
1800 /// 2. **Legacy per-recipient records** ([`storage_keys::PENDING_MESSAGES`]),
1801 /// migrated forward: read the queue, and once the caps have been applied
1802 /// write each *surviving* entry as its own record and drop the legacy one
1803 /// ([`Self::persist_migrated_pending_queues`]). A loss there is still only
1804 /// reportable per peer, because every id was inside the record that would
1805 /// not open.
1806 ///
1807 /// The migration writes before it deletes, so a crash in between leaves the
1808 /// entries in *both* layouts rather than neither. Pass 1 runs first and
1809 /// claims those ids — recovered or settled — so pass 2 skips them and the
1810 /// next launch converges.
1811 ///
1812 /// Neither pass needs the freeze the per-recipient walk did. Nothing outside
1813 /// this walk writes or deletes the legacy category any more, and a
1814 /// per-message record is only ever written or deleted under its own id — so
1815 /// an unwalked record is simply read by a later launch, which is the same
1816 /// argument [`Self::restore_outbox`] makes for the outbox.
1817 ///
1818 /// `allowance` is this walk's own pool, built by the launch — see
1819 /// [`PruneAllowance::pool`] and [`MAX_RESTORE_PRUNE_DELETES`].
1820 pub(crate) fn restore_pending_messages(
1821 &mut self,
1822 allowance: &mut PruneAllowance,
1823 ) -> Result<()> {
1824 let Some(storage) = self.protocol_state_storage.clone() else {
1825 return Ok(());
1826 };
1827
1828 // Counting rather than refusing: every delete either pass issues is
1829 // paired with a settlement, and a settlement for a record still on disk
1830 // is a claim the next launch contradicts. So the walk spends its
1831 // allowance, then stops at the next *record boundary* — the one place
1832 // stopping is safe — and leaves the rest for a later launch.
1833 //
1834 // Its own pool rather than the launch-wide advisory one: being starved
1835 // here means every diagnostic this walk owes is deferred, which must not
1836 // be a consequence of a key-package flood in an unrelated category. The
1837 // pool is passed in rather than built here — see `PruneAllowance::pool`.
1838 let mut budget = allowance.counting();
1839 let mut walk = PendingRestoreWalk::default();
1840
1841 self.restore_pending_message_entries(storage.as_ref(), &mut budget, &mut walk)?;
1842 self.migrate_legacy_pending_queues(storage.as_ref(), &mut budget, &mut walk)?;
1843
1844 // Records come back in whatever order the store enumerates them, which
1845 // carries no ordering information at all, so the canonical order has to
1846 // be re-imposed before the oldest-first trims below mean anything.
1847 // Recipients are visited in a stable order for the same reason: the
1848 // global caps evict across peers, so a map's iteration order would make
1849 // *which* entries survive a coin flip.
1850 let mut admission = RestoredPendingAdmission::default();
1851 let mut grouped: Vec<(String, Vec<PendingMessage>)> =
1852 std::mem::take(&mut walk.grouped).into_iter().collect();
1853 grouped.sort_by(|left, right| left.0.cmp(&right.0));
1854 for (recipient, mut messages) in grouped {
1855 sort_pending_queue(&mut messages);
1856 self.admit_restored_pending_queue(&recipient, messages, &mut admission);
1857 }
1858
1859 let RestoredPendingAdmission {
1860 capacity_evicted, ..
1861 } = admission;
1862
1863 // The migration finishes *before* anything it produced is settled. A
1864 // legacy record still on disk can re-file every id it holds, so a
1865 // settlement for one of its entries would be a terminal answer a later
1866 // launch overturns — the recipients it could not finish come back here
1867 // as `abandoned`, with their entries already taken back out of memory.
1868 let abandoned = self.persist_migrated_pending_queues(&mut budget, &mut walk);
1869
1870 // Every id the caps dropped had its own record, so the record goes with
1871 // the settlement rather than the survivors being rewritten around it —
1872 // unless it came out of a legacy queue, which has no per-message record
1873 // yet because the persist above writes only what survived.
1874 //
1875 // Budgeted like the outbox's capacity drain, and skipped between entries
1876 // for the same reason: an entry the pool cannot fund is already out of
1877 // memory and is left on disk **unsettled**, so a later launch restores
1878 // it, re-caps it, and owns both halves then.
1879 let mut capacity_settlements = Vec::new();
1880 for message_id in capacity_evicted {
1881 match walk.migrated.get(&message_id.as_str()) {
1882 // Recovered from a legacy record that is still on disk. That
1883 // record will re-file this entry next launch, which is where
1884 // both halves belong.
1885 Some(recipient) if abandoned.contains(recipient) => continue,
1886 // Recovered from a legacy record that is gone, and never written
1887 // under its own id — so there is no record to delete.
1888 Some(_) => {}
1889 // Its own record, which goes with the settlement.
1890 None => {
1891 if budget.is_spent() {
1892 walk.prune_bound_reached = true;
1893 continue;
1894 }
1895 budget.claim();
1896 self.delete_pending_message_from_storage(&message_id);
1897 }
1898 }
1899 capacity_settlements.push(Event::message_failed(
1900 message_id,
1901 "Pending session queue capacity exceeded".to_string(),
1902 0,
1903 ));
1904 }
1905 self.settle_restored_message_failures(capacity_settlements);
1906
1907 if walk.entry_bound_reached {
1908 warn!(
1909 examined = walk.examined_entries,
1910 cap = MAX_PENDING_RESTORE_ENTRIES,
1911 "Pending message store held more queued entries than one restore may walk; \
1912 the remainder is left on disk for a later launch"
1913 );
1914 }
1915 if walk.prune_bound_reached {
1916 warn!(
1917 deleted = budget.spent,
1918 budget = MAX_RESTORE_PRUNE_DELETES,
1919 "Pending message restore spent its per-launch delete budget; the remainder \
1920 is left on disk for a later launch"
1921 );
1922 }
1923 if walk.write_bound_reached {
1924 warn!(
1925 cap = MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH,
1926 "Legacy pending migration reached its per-launch write budget; the remaining \
1927 queues are left on disk for a later launch"
1928 );
1929 }
1930
1931 self.settle_restored_message_failures(walk.unaddressable.into_iter().map(|message_id| {
1932 Event::message_failed(
1933 message_id,
1934 "Recipient is not a valid user ID; queued message cannot be delivered".to_string(),
1935 0,
1936 )
1937 }));
1938 // Withheld, not emitted, when a surviving legacy record also holds the
1939 // id: that record re-files it next launch, so a terminal answer now
1940 // would be overturned — the same rule the `capacity_evicted` loop above
1941 // applies through `abandoned`.
1942 self.settle_restored_message_failures(
1943 walk.lost_ids
1944 .iter()
1945 .filter(|key| {
1946 !walk
1947 .seen_in_legacy
1948 .get(*key)
1949 .is_some_and(|recipient| abandoned.contains(recipient))
1950 })
1951 .map(|key| {
1952 Self::unrecoverable_message_settlement(
1953 key,
1954 "Queued message awaiting session establishment could not be recovered \
1955 from protocol-state storage",
1956 0,
1957 )
1958 }),
1959 );
1960 for recipient in &walk.lost_recipients {
1961 warn!(recipient = %recipient, "Persisted legacy pending queue was unreadable and has been dropped");
1962 }
1963 self.settle_restored_message_failures(walk.lost_recipients.into_iter().map(|recipient| {
1964 Event::convergence_diag(
1965 "pending_state_lost".to_string(),
1966 recipient,
1967 "Queued messages awaiting session establishment could not be recovered \
1968 from protocol-state storage and have been dropped"
1969 .to_string(),
1970 )
1971 }));
1972
1973 self.recompute_next_pending_message_expiry();
1974 self.cleanup_expired_pending_messages();
1975 Ok(())
1976 }
1977
1978 /// Gives every migrated entry that survived admission a record of its own,
1979 /// then drops the legacy record those entries came out of — one recipient
1980 /// at a time, and all or nothing.
1981 ///
1982 /// Returns the recipients whose legacy record is **still on disk**. Their
1983 /// entries have been taken back out of memory, so the queue lives in exactly
1984 /// one place — the legacy record — for a later launch to migrate again.
1985 ///
1986 /// # Why it has to be all or nothing
1987 ///
1988 /// Nothing outside this walk writes or deletes the legacy category — the
1989 /// adoption sweep does write it, but strictly earlier in the same launch
1990 /// (see [`Self::delete_legacy_pending_queue`]) — so a
1991 /// legacy record left behind is left behind for good. The per-message pass
1992 /// claiming its ids only converges while those per-message records exist,
1993 /// and they do not survive the flush this launch is about to perform:
1994 /// dispatch deletes each one. A later launch would then find the legacy
1995 /// record with nothing claiming it and re-file — and re-send — a queue that
1996 /// was already delivered. So a recipient whose delete does not land — or
1997 /// whose *writes* do not all land, since the legacy record must then keep
1998 /// holding the entries the writes lost — is not half-migrated; it is not
1999 /// migrated.
2000 ///
2001 /// Undoing is cheap because the funding checks come *before* the writes: a
2002 /// recipient this launch cannot pay for has nothing on disk to take back.
2003 ///
2004 /// # Why it runs after the caps
2005 ///
2006 /// A pre-cap legacy install can hold far more than the current bounds admit,
2007 /// and writing each entry out only to delete it again moments later is the
2008 /// one shape that turns an ordinary upgrade into thousands of device
2009 /// barriers on the boot path. Writing only the survivors, and only
2010 /// [`MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH`] of them, bounds the burst.
2011 fn persist_migrated_pending_queues(
2012 &mut self,
2013 budget: &mut PruneBudget<'_>,
2014 walk: &mut PendingRestoreWalk,
2015 ) -> HashSet<String> {
2016 let mut abandoned = HashSet::new();
2017 let recipients = std::mem::take(&mut walk.migrated_recipients);
2018 let mut written = 0usize;
2019 for recipient in recipients {
2020 // Both bounds are checked before a single byte is written, which is
2021 // what makes abandoning free: there is nothing on disk to undo.
2022 // A delete is owed for every recipient that gets this far, so the
2023 // pool has to be able to fund one.
2024 if budget.is_spent() {
2025 walk.prune_bound_reached = true;
2026 self.forget_migrated_entries(&recipient, &walk.migrated, false);
2027 abandoned.insert(recipient);
2028 continue;
2029 }
2030 let survivors = self
2031 .pending_encrypted_messages
2032 .get(&recipient)
2033 .map_or(0, |messages| {
2034 messages
2035 .iter()
2036 .filter(|message| walk.migrated.contains_key(&message.message_id.as_str()))
2037 .count()
2038 });
2039 if written.saturating_add(survivors) > MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH {
2040 walk.write_bound_reached = true;
2041 self.forget_migrated_entries(&recipient, &walk.migrated, false);
2042 abandoned.insert(recipient);
2043 continue;
2044 }
2045
2046 // The writes are fallible too, not just the delete. Deleting the
2047 // legacy record on top of a failed write (a full disk fails the
2048 // `store` and lets the `delete` through) would destroy the only
2049 // durable copy of every entry the write did not land — so a
2050 // recipient with any failed write is abandoned exactly like one
2051 // whose delete failed, and the writes that did land are taken
2052 // back.
2053 let mut wrote_all = true;
2054 if let Some(messages) = self.pending_encrypted_messages.get(&recipient) {
2055 for message in messages {
2056 if walk.migrated.contains_key(&message.message_id.as_str()) {
2057 wrote_all &= self.persist_pending_message(&recipient, message);
2058 }
2059 }
2060 }
2061 if !wrote_all {
2062 self.forget_migrated_entries(&recipient, &walk.migrated, true);
2063 abandoned.insert(recipient);
2064 continue;
2065 }
2066 written = written.saturating_add(survivors);
2067
2068 budget.claim();
2069 if !self.delete_legacy_pending_queue(&recipient) {
2070 self.forget_migrated_entries(&recipient, &walk.migrated, true);
2071 abandoned.insert(recipient);
2072 }
2073 }
2074 abandoned
2075 }
2076
2077 /// Takes one recipient's migrated entries back out of memory, and out of
2078 /// storage when they had already been written.
2079 ///
2080 /// The legacy record still holds this whole queue. Entries left in memory
2081 /// would flush, and dispatch would delete their per-message records — so
2082 /// nothing would be left to claim the ids that surviving record re-files on
2083 /// a later launch. Keeping the queue in exactly one place is the whole
2084 /// invariant; see [`Self::persist_migrated_pending_queues`].
2085 ///
2086 /// Entries the *per-message* pass restored for the same recipient stay: they
2087 /// have their own records and are not the legacy record's to re-file.
2088 fn forget_migrated_entries(
2089 &mut self,
2090 recipient: &str,
2091 migrated: &HashMap<String, String>,
2092 persisted: bool,
2093 ) {
2094 let mut removed = Vec::new();
2095 if let Some(messages) = self.pending_encrypted_messages.get_mut(recipient) {
2096 messages.retain(|message| {
2097 if migrated.contains_key(&message.message_id.as_str()) {
2098 removed.push(message.message_id.clone());
2099 false
2100 } else {
2101 true
2102 }
2103 });
2104 }
2105 if self
2106 .pending_encrypted_messages
2107 .get(recipient)
2108 .is_some_and(Vec::is_empty)
2109 {
2110 self.pending_encrypted_messages.remove(recipient);
2111 }
2112 if persisted {
2113 self.delete_pending_messages_from_storage(removed.iter());
2114 }
2115 // Nothing taken back means nothing was recovered from this record this
2116 // launch — a previous launch's interrupted migration already claimed
2117 // every id in it, and only the leftover delete failed (already logged).
2118 // Warning "could not be migrated" there would report a success as an
2119 // error.
2120 if !removed.is_empty() {
2121 warn!(
2122 recipient = %recipient,
2123 count = removed.len(),
2124 "Legacy pending queue could not be migrated this launch; leaving it on disk for a later one"
2125 );
2126 }
2127 }
2128
2129 /// Reads the per-message pending records, grouping them by recipient.
2130 ///
2131 /// Shaped like [`Self::restore_outbox`], because it is now the same problem:
2132 /// one record per id, each loss individually settleable, no freeze.
2133 fn restore_pending_message_entries(
2134 &mut self,
2135 storage: &dyn ProtocolStateStorage,
2136 budget: &mut PruneBudget<'_>,
2137 walk: &mut PendingRestoreWalk,
2138 ) -> Result<()> {
2139 let keys = Self::list_state_keys(storage, storage_keys::PENDING_MESSAGE_ENTRIES)
2140 .map_err(|e| Error::Other(format!("Failed to list pending messages: {}", e)))?;
2141 let listed = keys.len();
2142
2143 for key in keys.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2144 if walk.examined_entries >= MAX_PENDING_RESTORE_ENTRIES {
2145 walk.entry_bound_reached = true;
2146 break;
2147 }
2148 if budget.is_spent() {
2149 walk.prune_bound_reached = true;
2150 break;
2151 }
2152 let data = match self.read_state_record_detailed_budgeted(
2153 storage,
2154 storage_keys::PENDING_MESSAGE_ENTRIES,
2155 &key,
2156 Some(budget),
2157 ) {
2158 Ok(StateRecord::Present(data)) => data,
2159 Ok(StateRecord::Missing) => continue,
2160 // Examined and destroyed. The key *is* the id the application is
2161 // holding, so unlike the legacy layout this is settleable on its
2162 // own without having opened the record.
2163 Ok(StateRecord::Unreadable) => {
2164 warn!(message_id = %key, "Dropping unreadable pending message");
2165 walk.seen.insert(key.clone());
2166 walk.lost_ids.push(key);
2167 continue;
2168 }
2169 // Still on disk and probably intact — the record key is not
2170 // loaded, or the backend refused this read. Settling it now would
2171 // be a terminal answer the next launch overturns by restoring the
2172 // entry and flushing it.
2173 Ok(StateRecord::Unavailable) | Err(_) => {
2174 warn!(
2175 message_id = %key,
2176 "Pending message could not be read this session; leaving it in place"
2177 );
2178 continue;
2179 }
2180 };
2181 walk.examined_entries = walk.examined_entries.saturating_add(1);
2182
2183 let record = match serde_json::from_slice::<PendingMessageRecord>(&data) {
2184 Ok(record) => record,
2185 Err(e) => {
2186 warn!(message_id = %key, error = %e, "Dropping corrupted pending message");
2187 // Charged, never refused: the settlement below is already
2188 // owed, so the record has to go with it.
2189 budget.claim();
2190 let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2191 walk.seen.insert(key.clone());
2192 walk.lost_ids.push(key);
2193 continue;
2194 }
2195 };
2196
2197 // A record whose id disagrees with its key is tampered or corrupt,
2198 // and is worse than merely unparseable: it is addressed under one id
2199 // and settles under another, so nothing on the runtime path could
2200 // ever delete it. Treat it as the loss it is.
2201 if record.message.message_id.as_str() != key {
2202 warn!(
2203 message_id = %key,
2204 record_id = %record.message.message_id,
2205 "Dropping pending message whose record does not name its own key"
2206 );
2207 budget.claim();
2208 let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2209 walk.seen.insert(key.clone());
2210 walk.lost_ids.push(key);
2211 continue;
2212 }
2213
2214 if Self::validate_outbound_recipient(&record.recipient).is_err() {
2215 // Nothing can ever be sent to this recipient again, so the record
2216 // goes — but the app still holds the id from `send_message*` and
2217 // it has to be settled.
2218 warn!(
2219 message_id = %key,
2220 "Dropping persisted pending message for an invalid recipient"
2221 );
2222 budget.claim();
2223 let _ = storage.delete(storage_keys::PENDING_MESSAGE_ENTRIES, &key);
2224 walk.seen.insert(key);
2225 walk.unaddressable.push(record.message.message_id);
2226 continue;
2227 }
2228
2229 walk.seen.insert(key);
2230 walk.grouped
2231 .entry(record.recipient)
2232 .or_default()
2233 .push(record.message);
2234 }
2235
2236 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2237 warn!(
2238 listed,
2239 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2240 "Pending message store listed more records than any legitimate run can \
2241 produce; the tail is left on disk for a later launch"
2242 );
2243 }
2244 Ok(())
2245 }
2246
2247 /// Recovers legacy per-recipient queues so the caps can be applied to them.
2248 ///
2249 /// Recovery only — [`Self::persist_migrated_pending_queues`] does the writing
2250 /// and the legacy delete, after admission, so entries the caps drop are never
2251 /// written at all.
2252 ///
2253 /// Shares the walk's entry bound and delete budget with the per-message
2254 /// pass, which runs first — so a store holding
2255 /// [`MAX_PENDING_RESTORE_ENTRIES`] or more per-message entries starves this
2256 /// pass for the whole launch. Nothing is lost: this category is read-only
2257 /// and never grows, and the per-message population drains over successive
2258 /// launches (flushes, expiries, capacity evictions), so the migration
2259 /// eventually gets its turn.
2260 ///
2261 /// One-shot upgrade scaffolding, like `adopt_legacy_protocol_state`: once a
2262 /// launch has walked every legacy record the listing comes back empty and
2263 /// this costs one `list_keys`. Retire it on the same trigger as that sweep —
2264 /// when the oldest supported upgrade path starts at or after the release
2265 /// that introduced the per-message layout, not merely one release later.
2266 fn migrate_legacy_pending_queues(
2267 &mut self,
2268 storage: &dyn ProtocolStateStorage,
2269 budget: &mut PruneBudget<'_>,
2270 walk: &mut PendingRestoreWalk,
2271 ) -> Result<()> {
2272 let recipients = Self::list_state_keys(storage, storage_keys::PENDING_MESSAGES)
2273 .map_err(|e| Error::Other(format!("Failed to list legacy pending messages: {}", e)))?;
2274 if recipients.is_empty() {
2275 return Ok(());
2276 }
2277 let listed = recipients.len();
2278
2279 for recipient in recipients.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2280 if walk.examined_entries >= MAX_PENDING_RESTORE_ENTRIES {
2281 walk.entry_bound_reached = true;
2282 break;
2283 }
2284 if budget.is_spent() {
2285 walk.prune_bound_reached = true;
2286 break;
2287 }
2288 if Self::validate_outbound_recipient(&recipient).is_err() {
2289 // The queue is unaddressable — nothing can ever be sent to this
2290 // recipient again — so it is dropped, but the app still holds
2291 // ids from `send_message*` that must be settled. The record has
2292 // to be read *before* deleting it to recover them, and the read
2293 // gets the same three-way treatment as every other restore.
2294 match self.load_legacy_pending_queue(&recipient, Some(budget)) {
2295 PendingRestore::Restored(messages) => {
2296 walk.examined_entries =
2297 walk.examined_entries.saturating_add(messages.len());
2298 walk.unaddressable
2299 .extend(messages.into_iter().map(|m| m.message_id));
2300 }
2301 // Listed but no longer there. Nothing was destroyed, so
2302 // nothing is owed — and the delete below would charge the
2303 // budget, log a destruction, and make a provider round trip
2304 // for a record that is already gone.
2305 PendingRestore::Absent => continue,
2306 // Already examined and destroyed by the read; the ids went
2307 // with it, so report the loss per recipient rather than
2308 // letting an unaddressable queue vanish more quietly than an
2309 // addressable one would.
2310 PendingRestore::Lost => {
2311 walk.lost_recipients.push(recipient);
2312 continue;
2313 }
2314 // Intact on disk, just unreadable this session. Deleting it
2315 // now would destroy ids nothing can name; a later launch
2316 // reads the record and settles them properly.
2317 PendingRestore::Unavailable => {
2318 warn!(
2319 recipient = %recipient,
2320 "Legacy pending queue for an invalid recipient could not be read this session; leaving it in place"
2321 );
2322 continue;
2323 }
2324 }
2325 warn!(recipient = %recipient, "Dropping legacy pending queue for an invalid recipient");
2326 // Charged, never refused: the settlement this delete pairs with
2327 // was already recorded above, so the record has to go with it.
2328 // The return is deliberately not honoured, unlike at the
2329 // migration call sites: this queue is unaddressable, so a
2330 // surviving record cannot re-*send* anything — the next launch
2331 // merely reads it and settles the same ids again, and a
2332 // duplicate terminal event is the acceptable end of that.
2333 budget.claim();
2334 let _ = self.delete_legacy_pending_queue(&recipient);
2335 continue;
2336 }
2337
2338 let messages = match self.load_legacy_pending_queue(&recipient, Some(budget)) {
2339 PendingRestore::Restored(messages) => messages,
2340 PendingRestore::Absent => continue,
2341 // A queue existed and its contents are gone. The ids are inside
2342 // the record we could not open, so they cannot be settled
2343 // individually — surface the loss per recipient instead of
2344 // letting it read as "there was nothing queued". This is the
2345 // reporting the per-message layout exists to improve on, and it
2346 // cannot be improved retroactively.
2347 PendingRestore::Lost => {
2348 walk.lost_recipients.push(recipient);
2349 continue;
2350 }
2351 // Not readable this session, but still on disk. Say nothing: a
2352 // later launch is expected to read it, and a loss diagnostic now
2353 // would be a claim the next launch contradicts.
2354 PendingRestore::Unavailable => {
2355 warn!(
2356 recipient = %recipient,
2357 "Legacy pending queue could not be read this session; leaving it in place"
2358 );
2359 continue;
2360 }
2361 };
2362 walk.examined_entries = walk.examined_entries.saturating_add(messages.len());
2363
2364 // Recovered, not yet written. The caller persists what survives the
2365 // caps and then drops this record, so a queue too big for the
2366 // current bounds is never written out only to be deleted again.
2367 let mut migrated = 0usize;
2368 for message in messages {
2369 let message_id = message.message_id.as_str();
2370 if walk.seen.contains(&message_id) {
2371 // Already accounted for by the pass before this one — either
2372 // a previous launch migrated this queue and died before
2373 // dropping it, or the per-message record was examined and
2374 // settled. Re-filing a settled id would put a message on the
2375 // wire after the app was told it failed. Remember where the
2376 // legacy copy lives: if this record's delete fails, a
2377 // settlement for the id has to be withheld, because the
2378 // surviving record re-files it next launch.
2379 walk.seen_in_legacy.insert(message_id, recipient.clone());
2380 continue;
2381 }
2382 walk.seen.insert(message_id.clone());
2383 walk.migrated.insert(message_id, recipient.clone());
2384 walk.grouped
2385 .entry(recipient.clone())
2386 .or_default()
2387 .push(message);
2388 migrated = migrated.saturating_add(1);
2389 }
2390 info!(
2391 recipient = %recipient,
2392 migrated,
2393 "Recovered legacy pending queue for migration"
2394 );
2395 walk.migrated_recipients.push(recipient);
2396 }
2397
2398 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2399 warn!(
2400 listed,
2401 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2402 "Legacy pending message store listed more recipients than any legitimate run \
2403 can produce; the tail is left on disk for a later launch"
2404 );
2405 }
2406 Ok(())
2407 }
2408
2409 // ========================================================================
2410 // PEER KEY PACKAGES PERSISTENCE
2411 // ========================================================================
2412
2413 /// Persists a received key package for a peer so it survives restart.
2414 pub(crate) fn persist_peer_key_package(&self, peer_id: &str, pkg: &ReceivedKeyPackage) {
2415 let Some(storage) = &self.protocol_state_storage else {
2416 return;
2417 };
2418 match serde_json::to_vec(pkg) {
2419 Ok(data) => {
2420 if let Err(e) = self.write_state_record(
2421 storage.as_ref(),
2422 storage_keys::PEER_KEY_PACKAGES,
2423 peer_id,
2424 &data,
2425 ) {
2426 warn!(peer_id = %peer_id, error = %e, "Failed to persist peer key package");
2427 }
2428 }
2429 Err(e) => {
2430 warn!(peer_id = %peer_id, error = %e, "Failed to serialize peer key package");
2431 }
2432 }
2433 }
2434
2435 /// Loads a persisted key package for a peer (if present and not expired).
2436 ///
2437 /// Unbudgeted, for the runtime callers: one expired record dropped while
2438 /// resolving a single peer is one provider round trip, not a storm.
2439 pub(crate) fn load_peer_key_package_from_storage(
2440 &self,
2441 peer_id: &str,
2442 ) -> Option<ReceivedKeyPackage> {
2443 self.load_peer_key_package_bounded(peer_id, None)
2444 }
2445
2446 /// Loads a persisted key package, charging the drop of an expired record to
2447 /// `budget` when one is supplied.
2448 ///
2449 /// Restore supplies one. The expiry drop is a durable delete like any other
2450 /// on that path — a synchronous provider round trip that flushes the
2451 /// containing directory on all three built-in stores — and it is the
2452 /// *common* one there: an over-cap key-package store is over-cap because it
2453 /// is old, and [`MAX_KEY_PACKAGE_LIFETIME_MS`] is 30 days, so most of what
2454 /// a restore walk finds in such a store has expired. Left unbudgeted it
2455 /// also bypassed the over-cap prune entirely, because an expired record
2456 /// never enters `pending_key_packages` and so never makes the cap bind.
2457 ///
2458 /// A record the budget spares is still not returned — it has expired either
2459 /// way — it is simply left on disk for a later launch to drop, which is the
2460 /// same idempotent-and-resumable property the rest of the prune relies on.
2461 fn load_peer_key_package_bounded(
2462 &self,
2463 peer_id: &str,
2464 mut budget: Option<&mut PruneBudget<'_>>,
2465 ) -> Option<ReceivedKeyPackage> {
2466 let storage = self.protocol_state_storage.as_ref()?;
2467 // The reader deletes what it cannot return, so it gets the budget too —
2468 // that delete is as durable as the expiry one below and was the last
2469 // one on this walk that nothing counted.
2470 let data = self
2471 .read_state_record_detailed_budgeted(
2472 storage.as_ref(),
2473 storage_keys::PEER_KEY_PACKAGES,
2474 peer_id,
2475 budget.as_deref_mut(),
2476 )
2477 .ok()?
2478 .into_bytes()?;
2479 let pkg: ReceivedKeyPackage = serde_json::from_slice(&data).ok()?;
2480 let now_ms = Utc::now().timestamp_millis() as u64;
2481 if now_ms >= pkg.local_expires_at_ms {
2482 if claim_prune(&mut budget) {
2483 let _ = storage.delete(storage_keys::PEER_KEY_PACKAGES, peer_id);
2484 }
2485 return None;
2486 }
2487 Some(pkg)
2488 }
2489
2490 /// Removes persisted key package for a peer (e.g. after session created).
2491 pub(crate) fn delete_peer_key_package_from_storage(&self, peer_id: &str) {
2492 if let Some(storage) = &self.protocol_state_storage {
2493 let _ = storage.delete(storage_keys::PEER_KEY_PACKAGES, peer_id);
2494 }
2495 }
2496
2497 /// Loads key package from storage into memory if not already present. Returns true if we now have one in memory.
2498 pub(crate) fn try_load_key_package_from_storage_into_memory(&mut self, peer_id: &str) -> bool {
2499 if self.pending_key_packages.contains_key(peer_id) {
2500 return true;
2501 }
2502 if let Some(pkg) = self.load_peer_key_package_from_storage(peer_id) {
2503 self.pending_key_packages.insert(peer_id.to_string(), pkg);
2504 return true;
2505 }
2506 false
2507 }
2508
2509 /// Restores peer key packages from storage for peers that have no MLS session.
2510 pub(crate) fn restore_peer_key_packages(
2511 &mut self,
2512 mls: &Arc<RwLock<MlsManager>>,
2513 allowance: &mut PruneAllowance,
2514 ) -> Result<()> {
2515 let Some(storage) = &self.protocol_state_storage else {
2516 return Ok(());
2517 };
2518
2519 let peer_ids = Self::list_state_keys(storage.as_ref(), storage_keys::PEER_KEY_PACKAGES)
2520 .map_err(|e| Error::Other(format!("Failed to list peer key packages: {}", e)))?;
2521 let listed = peer_ids.len();
2522
2523 let sessions = {
2524 let manager = mls
2525 .read()
2526 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
2527 manager.list_sessions().map_err(Error::Mls)?
2528 };
2529 let session_set: std::collections::HashSet<_> = sessions.into_iter().collect();
2530
2531 let mut budget = allowance.refusing();
2532 let mut over_cap_pruned = 0usize;
2533 // Bounded like every other category walk, and the prune inside it is
2534 // bounded again by `MAX_RESTORE_PRUNE_DELETES` — the walk bounds reads,
2535 // the budget bounds deletes, and a delete is the far more expensive of
2536 // the two (a synchronous provider round trip that flushes a directory
2537 // on all three built-in stores). Both tails drain over successive
2538 // launches rather than stranding.
2539 //
2540 // *Both* kinds of delete this walk issues share that budget: the
2541 // over-cap prune below and the expiry drop inside
2542 // `load_peer_key_package_bounded`. They are not alternatives — an
2543 // expired record never enters the map, so it never makes the cap bind,
2544 // which means an over-cap store full of expired records would otherwise
2545 // route every one of its deletes past the budget.
2546 for peer_id in peer_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2547 if session_set.contains(&peer_id) {
2548 continue;
2549 }
2550 // Bound restore to the same cap as the live insert path so a
2551 // pre-existing over-cap durable store (e.g. a flood that landed
2552 // before the cap existed) cannot re-inflate memory on boot. Rather
2553 // than leaving the overflow to linger on disk forever — where it
2554 // would re-inflate memory on a future boot and waste durable
2555 // storage — prune it so the store shrinks toward the cap. Dropping
2556 // a cached package only costs a recoverable re-exchange, exactly
2557 // like the live eviction path. Overflow is deleted without loading
2558 // it, so peak memory stays cap-bounded.
2559 if self.pending_key_packages.len() >= MAX_PENDING_KEY_PACKAGES {
2560 if !budget.claim() {
2561 // The map only grows and the cap only binds harder, so
2562 // every remaining key would take this same branch. Nothing
2563 // is left to restore and nothing more may be deleted.
2564 break;
2565 }
2566 self.delete_peer_key_package_from_storage(&peer_id);
2567 over_cap_pruned += 1;
2568 continue;
2569 }
2570 if let Some(pkg) = self.load_peer_key_package_bounded(&peer_id, Some(&mut budget)) {
2571 info!(peer_id = %peer_id, "Restored peer key package from storage");
2572 self.pending_key_packages.insert(peer_id, pkg);
2573 }
2574 }
2575 if over_cap_pruned > 0 {
2576 warn!(
2577 cap = MAX_PENDING_KEY_PACKAGES,
2578 pruned = over_cap_pruned,
2579 "Peer key package store exceeded the cap on restore; pruned overflow from durable storage"
2580 );
2581 }
2582 // Routine, unlike the over-cap prune above: cached packages expire and
2583 // nothing else collects them, so this is the ordinary way the store
2584 // shrinks. Reported at debug so a normal launch stays quiet. Folded in
2585 // with the reader's own drops, which are neither routine nor a flood —
2586 // separating a third counter would say more about the log line than
2587 // about the store.
2588 let dropped = budget.spent.saturating_sub(over_cap_pruned);
2589 if dropped > 0 {
2590 debug!(
2591 pruned = dropped,
2592 "Dropped expired or unreadable peer key packages from durable storage on restore"
2593 );
2594 }
2595 if budget.exhausted {
2596 warn!(
2597 deleted = budget.spent,
2598 budget = MAX_RESTORE_PRUNE_DELETES,
2599 "Peer key package prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
2600 );
2601 }
2602 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2603 warn!(
2604 listed,
2605 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2606 "Peer key package store listed more peers than any legitimate run can produce; deferring the tail to a later launch"
2607 );
2608 }
2609
2610 Ok(())
2611 }
2612
2613 // ========================================================================
2614 // PEER CAPABILITIES PERSISTENCE
2615 // ========================================================================
2616
2617 /// Persists the capability versions a peer advertised in its key package
2618 /// so they survive restarts (see [`PeerCapabilities`] for why this is a
2619 /// separate record from the key package itself). Best-effort like the
2620 /// other persist paths: a lost record only degrades output (JSON
2621 /// envelope, dropped rich extras) until the next live exchange.
2622 pub(crate) fn persist_peer_capabilities(&self, peer_id: &str, caps: &PeerCapabilities) {
2623 let Some(storage) = &self.protocol_state_storage else {
2624 return;
2625 };
2626 match serde_json::to_vec(caps) {
2627 Ok(data) => {
2628 if let Err(e) = self.write_state_record(
2629 storage.as_ref(),
2630 storage_keys::PEER_CAPABILITIES,
2631 peer_id,
2632 &data,
2633 ) {
2634 warn!(peer_id = %peer_id, error = %e, "Failed to persist peer capabilities");
2635 }
2636 }
2637 Err(e) => {
2638 warn!(peer_id = %peer_id, error = %e, "Failed to serialize peer capabilities");
2639 }
2640 }
2641 }
2642
2643 /// Removes the persisted capability record for a peer (downgrade,
2644 /// eviction, or peer-state cleanup).
2645 pub(crate) fn delete_peer_capabilities_from_storage(&self, peer_id: &str) {
2646 if let Some(storage) = &self.protocol_state_storage {
2647 let _ = storage.delete(storage_keys::PEER_CAPABILITIES, peer_id);
2648 }
2649 }
2650
2651 /// Loads the persisted capability record for a peer.
2652 ///
2653 /// Three answers, not two, for the same reason [`StateRecord`] has three.
2654 /// `Ok(None)` is "there is nothing here to merge with"; `Err(())` is "a
2655 /// record may well be here and could not be read *this session*". The only
2656 /// caller merges into whatever this returns and then writes the result
2657 /// back, so collapsing the two would let one transient provider failure
2658 /// write an attested-only record over a peer's *directly advertised*
2659 /// capabilities — silently downgrading its MLS envelope and dropping its
2660 /// rich extras until the next live key-package exchange.
2661 ///
2662 /// A record that is present but will not deserialize is `Ok(None)`:
2663 /// nothing can be merged with bytes that do not parse, and the write that
2664 /// follows is the recovery. Same for one the store itself destroyed.
2665 fn load_peer_capabilities(
2666 &self,
2667 peer_id: &str,
2668 ) -> std::result::Result<Option<PeerCapabilities>, ()> {
2669 let Some(storage) = self.protocol_state_storage.as_ref() else {
2670 return Ok(None);
2671 };
2672 match self.read_state_record_detailed(
2673 storage.as_ref(),
2674 storage_keys::PEER_CAPABILITIES,
2675 peer_id,
2676 ) {
2677 Ok(StateRecord::Present(data)) => Ok(serde_json::from_slice(&data).ok()),
2678 // Never written, or examined and destroyed: nothing to preserve.
2679 Ok(StateRecord::Missing | StateRecord::Unreadable) => Ok(None),
2680 // Still on disk and probably intact — merging into a default and
2681 // persisting that would destroy it.
2682 Ok(StateRecord::Unavailable) | Err(_) => Err(()),
2683 }
2684 }
2685
2686 /// Reads the durable capability record for a peer, as an accessor over the
2687 /// module-private [`Self::load_peer_capabilities`].
2688 ///
2689 /// Collapses "never written" and "could not be read this session" into the
2690 /// same `None`, which the one production caller
2691 /// (`handle_key_package_message`, carrying a stored `nostr_pubkey` forward
2692 /// past an unsigned package) can afford: losing the value there degrades
2693 /// that peer to bootstrap-key sealing, which still delivers. A caller that
2694 /// needs to distinguish them — because writing a default would *destroy* a
2695 /// record that is merely unreadable — must use `load_peer_capabilities`
2696 /// directly and honour its `Err`, as the attested-capability merge does.
2697 pub(crate) fn load_peer_capabilities_record(&self, peer_id: &str) -> Option<PeerCapabilities> {
2698 self.load_peer_capabilities(peer_id).ok().flatten()
2699 }
2700
2701 /// The Nostr public key an outgoing key package would advertise.
2702 /// Test-only accessor for the value `send_key_package_to` embeds.
2703 #[cfg(test)]
2704 pub(crate) fn outgoing_key_package_nostr_pubkey_for_test(&self) -> Option<String> {
2705 self.transport_manager.nostr_public_key()
2706 }
2707
2708 /// Records an inviter-attested rich-payload capability for a peer we
2709 /// never directly exchanged key packages with (a group member added by
2710 /// someone else). In-memory recording mirrors the direct path in
2711 /// `handle_key_package_message`: gated by our own kill switch and
2712 /// bounded like `key_package_sent_to`. Persistence stores the raw
2713 /// attested versions merged into the peer's existing capability record
2714 /// (never clobbering directly-advertised fields — which is why a record
2715 /// that cannot be *read* this session skips the write rather than merging
2716 /// into a default), matching the "switches gate use, not knowledge" rule.
2717 /// A later direct key-package exchange overwrites the whole record and
2718 /// evicts the in-memory entry — direct knowledge is always authoritative.
2719 pub(crate) fn record_attested_rich(&mut self, peer_id: &str, versions: &[u8]) {
2720 if peer_id == self.local_id || !versions.contains(&RICH_PAYLOAD_V1) {
2721 return;
2722 }
2723 // Direct self-advertisement already covers this peer; an attested
2724 // duplicate would only go stale.
2725 if self.peer_rich_payload.contains(peer_id) {
2726 return;
2727 }
2728 if self.config.encryption.rich_payload_enabled {
2729 if !self.peer_rich_attested.contains(peer_id)
2730 && self.peer_rich_attested.len() >= MAX_KEY_PACKAGE_SENT_TO
2731 {
2732 self.peer_rich_attested.clear();
2733 }
2734 self.peer_rich_attested.insert(peer_id.to_string());
2735 }
2736 let Ok(existing) = self.load_peer_capabilities(peer_id) else {
2737 // The record could not be read this session, so a write now would
2738 // put an attested-only record over whatever is on disk — including
2739 // the versions this peer advertised for itself, which are the
2740 // authoritative ones. An attestation is advisory and the in-memory
2741 // set above already opens the group gate for this run; the next Add
2742 // commit re-attests. Skipping the write is strictly cheaper than
2743 // losing a peer's real capabilities to a transient read.
2744 warn!(
2745 peer_id = %peer_id,
2746 "Peer capability record unreadable this session; not persisting the attested capability"
2747 );
2748 return;
2749 };
2750 let mut caps = existing.unwrap_or_default();
2751 caps.attested_rich_versions = versions
2752 .iter()
2753 .copied()
2754 .take(MAX_PERSISTED_CAPABILITY_VERSIONS)
2755 .collect();
2756 self.persist_peer_capabilities(peer_id, &caps);
2757 }
2758
2759 /// Repopulates the in-memory capability sets (`peer_compact_envelope`,
2760 /// `peer_rich_payload`) from the durable per-peer records, so a send
2761 /// right after relaunch — before any live key-package exchange — keeps
2762 /// sealing rich extras and emitting the compact envelope.
2763 ///
2764 /// Config gating happens here, not at persist time: a record is only
2765 /// applied to a set whose kill switch is on, but a record gated off is
2766 /// left in storage (the switch may come back on next run). Records are
2767 /// bounded to `MAX_KEY_PACKAGE_SENT_TO` — the same cap the live insert
2768 /// path enforces on the sets — and overflow is pruned from durable
2769 /// storage like `restore_peer_key_packages` does, so a pre-existing
2770 /// over-cap store shrinks to the cap in a single boot. Session peers are
2771 /// admitted first: they are exactly the records this feature exists for
2772 /// (their key-package cache entry is gone), and `list_keys` order is
2773 /// backend-defined, so an unprioritized over-cap prune — reachable only
2774 /// after a forged-sender flood — could evict them while keeping forged
2775 /// leftovers. Best-effort: failures degrade output, never blocking
2776 /// restore.
2777 pub(crate) fn restore_peer_capabilities(
2778 &mut self,
2779 mls: &Arc<RwLock<MlsManager>>,
2780 allowance: &mut PruneAllowance,
2781 ) {
2782 let Some(storage) = self.protocol_state_storage.clone() else {
2783 return;
2784 };
2785 let peer_ids =
2786 match Self::list_state_keys(storage.as_ref(), storage_keys::PEER_CAPABILITIES) {
2787 Ok(ids) => ids,
2788 Err(e) => {
2789 warn!(error = %e, "Failed to list peer capabilities, skipping restore");
2790 return;
2791 }
2792 };
2793
2794 // Best-effort session lookup: if it fails, restore proceeds in
2795 // backend order rather than not at all.
2796 let session_set: std::collections::HashSet<String> = mls
2797 .read()
2798 .ok()
2799 .and_then(|manager| manager.list_sessions().ok())
2800 .map(|sessions| sessions.into_iter().collect())
2801 .unwrap_or_default();
2802 let listed = peer_ids.len();
2803 let (mut peer_ids, non_session_ids): (Vec<_>, Vec<_>) = peer_ids
2804 .into_iter()
2805 .partition(|peer_id| session_set.contains(peer_id));
2806 peer_ids.extend(non_session_ids);
2807
2808 let mut kept = 0usize;
2809 let mut over_cap_pruned = 0usize;
2810 let mut budget = allowance.refusing();
2811 // Bounded like `restore_peer_key_packages`, for the same reason: the
2812 // prune below is a provider delete per over-cap entry, and those are
2813 // budgeted separately from the read walk. The take comes *after* the
2814 // session-peer partition, so the records this restore exists for are
2815 // always inside the prefix however the backend ordered its listing.
2816 //
2817 // *Every* kind of delete this walk causes shares that budget, including
2818 // the one it does not issue itself: a record the store reports corrupt,
2819 // or one over the record cap, is dropped inside the reader. Handing the
2820 // budget to the reader is the only way to count those — the same
2821 // correction `restore_media_descriptors` needed, for the same reason.
2822 for peer_id in peer_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
2823 if kept >= MAX_KEY_PACKAGE_SENT_TO {
2824 if !budget.claim() {
2825 // `kept` only grows, so every remaining key would take this
2826 // same branch: nothing left to restore, nothing more that
2827 // may be deleted.
2828 break;
2829 }
2830 self.delete_peer_capabilities_from_storage(&peer_id);
2831 over_cap_pruned += 1;
2832 continue;
2833 }
2834 let Ok(StateRecord::Present(data)) = self.read_state_record_detailed_budgeted(
2835 storage.as_ref(),
2836 storage_keys::PEER_CAPABILITIES,
2837 &peer_id,
2838 Some(&mut budget),
2839 ) else {
2840 continue;
2841 };
2842 let Ok(caps) = serde_json::from_slice::<PeerCapabilities>(&data) else {
2843 // Corrupt record: drop it rather than re-parsing it forever —
2844 // unless the delete budget is gone, in which case a later
2845 // launch drops it instead. Re-parsing one record is cheap; the
2846 // device barrier a delete costs is not.
2847 warn!(peer_id = %peer_id, "Corrupt peer capability record, deleting");
2848 if budget.claim() {
2849 self.delete_peer_capabilities_from_storage(&peer_id);
2850 }
2851 continue;
2852 };
2853 if !caps.is_any() {
2854 // Empty records are deleted at persist time; clean up any
2855 // that predate that rule.
2856 if budget.claim() {
2857 self.delete_peer_capabilities_from_storage(&peer_id);
2858 }
2859 continue;
2860 }
2861 if self.config.encryption.compact_envelope_enabled
2862 && caps.env_versions.contains(&MLS_ENVELOPE_COMPACT_V1)
2863 {
2864 self.peer_compact_envelope.insert(peer_id.clone());
2865 }
2866 if self.config.encryption.rich_payload_enabled
2867 && caps.rich_versions.contains(&RICH_PAYLOAD_V1)
2868 {
2869 self.peer_rich_payload.insert(peer_id.clone());
2870 }
2871 if self.config.encryption.rich_payload_enabled
2872 && caps.attested_rich_versions.contains(&RICH_PAYLOAD_V1)
2873 {
2874 self.peer_rich_attested.insert(peer_id.clone());
2875 }
2876 // Not gated on the sealing kill switch: this is a destination
2877 // address, and the transport decides whether to seal at all. A
2878 // restore that skipped it would leave the peer addressed by the
2879 // publicly computable bootstrap key until the next live key-package
2880 // exchange — a silent privacy regression across every restart,
2881 // which is the exact failure this whole record exists to prevent.
2882 if let Some(pubkey) = caps.nostr_pubkey.as_deref() {
2883 self.transport_manager
2884 .mark_peer_nostr_pubkey(&peer_id, Some(pubkey));
2885 }
2886 kept += 1;
2887 }
2888 if kept > 0 {
2889 info!(
2890 count = kept,
2891 "Restored peer capability records from storage"
2892 );
2893 }
2894 if over_cap_pruned > 0 {
2895 warn!(
2896 cap = MAX_KEY_PACKAGE_SENT_TO,
2897 pruned = over_cap_pruned,
2898 "Peer capability store exceeded the cap on restore; pruned overflow from durable storage"
2899 );
2900 }
2901 // Reported apart from the over-cap prune, like `restore_peer_key_packages`
2902 // separates its expiry drops: a corrupt, empty, or unreadable record is
2903 // not a store that outgrew its cap, and attributing it to one sends
2904 // whoever reads this log looking for a flood that never happened.
2905 let unreadable_pruned = budget.spent.saturating_sub(over_cap_pruned);
2906 if unreadable_pruned > 0 {
2907 debug!(
2908 pruned = unreadable_pruned,
2909 "Dropped unreadable peer capability records from durable storage on restore"
2910 );
2911 }
2912 if budget.exhausted {
2913 warn!(
2914 deleted = budget.spent,
2915 budget = MAX_RESTORE_PRUNE_DELETES,
2916 "Peer capability prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
2917 );
2918 }
2919 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
2920 warn!(
2921 listed,
2922 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
2923 "Peer capability store listed more peers than any legitimate run can produce; deferring the tail to a later launch"
2924 );
2925 }
2926 }
2927
2928 // ========================================================================
2929 // RESTORE-PATH RECORD DECODING
2930 // ========================================================================
2931
2932 /// Reads and decodes one JSON protocol-state record *for a restore walk*,
2933 /// dropping a record whose bytes are not this category's type rather than
2934 /// failing the walk.
2935 ///
2936 /// A record that parses as garbage is the same permanent loss as one that
2937 /// fails to open, and every other restore on this path already treats it
2938 /// that way ([`Self::restore_outbox`], [`Self::restore_pending_messages`],
2939 /// [`Self::restore_peer_capabilities`]). Session states and Welcome
2940 /// lifecycles were the two that did not: their loaders map a serde failure
2941 /// to an error, and their restores propagate it, so one unparseable record
2942 /// failed `initialize_mls` outright — and, because nothing deleted it,
2943 /// failed it again on every launch after that. With `require_encryption`
2944 /// on by default that install can no longer send anything, and there is no
2945 /// in-app recovery. Both categories are unsealed, so they carry no
2946 /// integrity protection at all, and both now live in the app container
2947 /// rather than the credential store — the same threat model every restore
2948 /// walk here is bounded against.
2949 ///
2950 /// Deliberately *not* folded into the loaders themselves. Those are also
2951 /// read at runtime, where `is_session_confirmed` propagates the error on
2952 /// purpose so a send fails closed instead of silently reading a Confirmed
2953 /// session as Pending. Restore is the only caller that must survive the
2954 /// record; the runtime one must not.
2955 ///
2956 /// A **storage failure no longer fails the walk either**, and that is the
2957 /// other half of the same lesson. Fixing only the serde case left the
2958 /// identical outcome reachable through the store: `list_keys` succeeds,
2959 /// one record's read returns `LoadFailed`, and `initialize_mls` rolls back
2960 /// — every launch, for as long as that one file stays unreadable, on an
2961 /// install that can then never send. Every other category on this path
2962 /// already treats a per-record read failure as recoverable and continues
2963 /// ([`Self::restore_outbox`], [`Self::load_legacy_pending_queue`],
2964 /// [`Self::restore_media_descriptors`], [`Self::load_peer_capabilities`]).
2965 /// These two were the outliers, and moving both categories out of the
2966 /// credential store and into the app container — where `ENOSPC`, `EIO`, and
2967 /// protection-class failures are ordinary — is what made it matter.
2968 ///
2969 /// So this reader is infallible and answers three ways.
2970 /// [`RestorableRecord::Unavailable`] is the load-bearing one: the caller
2971 /// must not read it as absence, because for session states "absent" means
2972 /// *re-bootstrap and persist `Pending`*, which would write over a record
2973 /// that may say `Confirmed`. A *listing* failure still propagates, from the
2974 /// restores themselves — it is indistinguishable from an empty category and
2975 /// has no per-record fallback.
2976 ///
2977 /// `budget` bounds the durable deletes this causes — the drop below, and the
2978 /// reader's own drop of a record that will not open. Both categories reached
2979 /// through here are unsealed and advisory-to-restore, so a refusing budget
2980 /// is right: a record the budget spares is simply re-walked and dropped on a
2981 /// later launch, exactly like the cache prunes. Callers whose walk is
2982 /// bounded by something other than the store
2983 /// (`restore_session_states_from_manager` iterates the MLS session list)
2984 /// pass `None`.
2985 fn load_restorable_state_record<T: DeserializeOwned>(
2986 &self,
2987 key_type: &str,
2988 key_id: &str,
2989 mut budget: Option<&mut PruneBudget<'_>>,
2990 ) -> RestorableRecord<T> {
2991 let Some(storage) = &self.protocol_state_storage else {
2992 return RestorableRecord::Absent;
2993 };
2994
2995 let data = match self.read_state_record_detailed_budgeted(
2996 storage.as_ref(),
2997 key_type,
2998 key_id,
2999 budget.as_deref_mut(),
3000 ) {
3001 Ok(StateRecord::Present(data)) => data,
3002 // Never written, or examined and destroyed: either way there is
3003 // nothing here to recover and the caller may re-bootstrap.
3004 Ok(StateRecord::Missing | StateRecord::Unreadable) => return RestorableRecord::Absent,
3005 // Still on disk and quite possibly intact.
3006 Ok(StateRecord::Unavailable) => return RestorableRecord::Unavailable,
3007 // `debug`, not `warn`: a store failing systemically fails *every*
3008 // read, and this reader runs once per record on a walk bounded at
3009 // `MAX_RESTORE_KEYS_PER_CATEGORY`. A warn here is up to that many
3010 // lines on the synchronous boot path, in exactly the degraded state
3011 // this three-way answer exists to survive. Each caller emits the
3012 // operator-facing signal itself and aggregates it:
3013 // `restore_welcome_lifecycles` counts them into one warn, and
3014 // `restore_session_states_from_manager` walks the far smaller MLS
3015 // session list and can afford one per peer.
3016 Err(e) => {
3017 debug!(
3018 key_type = %key_type,
3019 key_id = %key_id,
3020 error = %e,
3021 "Protocol state record could not be read this session; leaving it in place"
3022 );
3023 return RestorableRecord::Unavailable;
3024 }
3025 };
3026
3027 match serde_json::from_slice::<T>(&data) {
3028 Ok(value) => RestorableRecord::Present(value),
3029 Err(e) => {
3030 warn!(
3031 key_type = %key_type,
3032 key_id = %key_id,
3033 error = %e,
3034 "Dropping a protocol-state record whose bytes are not the type \
3035 its category holds"
3036 );
3037 if claim_prune(&mut budget) {
3038 let _ = storage.delete(key_type, key_id);
3039 }
3040 RestorableRecord::Absent
3041 }
3042 }
3043 }
3044
3045 /// Restore-path read of a peer's session state. See
3046 /// [`Self::load_restorable_state_record`] for why this is separate from
3047 /// [`Self::load_session_state_entry`].
3048 ///
3049 /// Budgeted, though its caller walks the *MLS session list* rather than a
3050 /// protocol-state category. The walk bound was once argued to be enough on
3051 /// the grounds that the volume is bounded by sessions this install actually
3052 /// has — but the MLS session list carries no restore cap of its own, so
3053 /// "bounded" there means "bounded by the peer count", and a store that
3054 /// reports every session-state record corrupt (or holds bytes that will not
3055 /// decode) turns that into one device barrier per peer on the boot path
3056 /// with nothing counting them. That is the same argument-from-the-wrong-
3057 /// number that exempted `restore_outbox`.
3058 ///
3059 /// A [`PruneAllowance::refusing`] budget is right here, and refusing costs
3060 /// less than anywhere else on the path: a spared record still reads
3061 /// [`StateRecord::Unreadable`] → [`RestorableRecord::Absent`], so the
3062 /// caller re-bootstraps and persists a fresh `Pending` **over** it. The
3063 /// record is repaired by that write whether or not the delete was funded —
3064 /// which makes refusing strictly cheaper than claiming, since it saves a
3065 /// barrier rather than deferring one.
3066 pub(super) fn load_session_state_for_restore(
3067 &self,
3068 peer_id: &str,
3069 budget: Option<&mut PruneBudget<'_>>,
3070 ) -> RestorableRecord<SessionState> {
3071 self.load_restorable_state_record(storage_keys::SESSION_STATES, peer_id, budget)
3072 }
3073
3074 /// Reads a peer's Welcome lifecycle. See
3075 /// [`Self::load_restorable_state_record`].
3076 ///
3077 /// Unlike session states, this category has no strict twin: restore is its
3078 /// only reader (`welcome_lifecycles` is an in-memory map from then on), so
3079 /// there is no send-path decision that has to fail closed on a record that
3080 /// will not decode. It *is* walked by container-listed key, though, so its
3081 /// deletes are budgeted like every other such walk.
3082 fn load_welcome_lifecycle_for_restore(
3083 &self,
3084 peer_id: &str,
3085 budget: Option<&mut PruneBudget<'_>>,
3086 ) -> RestorableRecord<WelcomeLifecycleRecord> {
3087 self.load_restorable_state_record(storage_keys::WELCOME_LIFECYCLES, peer_id, budget)
3088 }
3089
3090 // ========================================================================
3091 // SESSION STATE PERSISTENCE
3092 // ========================================================================
3093
3094 /// Loads a persisted session state entry (if present).
3095 ///
3096 /// A record that will not deserialize is an error here, not an absent
3097 /// record: `is_session_confirmed` reads this on the send path and must
3098 /// fail closed rather than treat a Confirmed session as Pending. The
3099 /// restore walk, which must not be blocked by one poison record, goes
3100 /// through [`Self::load_session_state_for_restore`] instead.
3101 pub(crate) fn load_session_state_entry(&self, peer_id: &str) -> Result<Option<SessionState>> {
3102 let Some(storage) = &self.protocol_state_storage else {
3103 return Ok(None);
3104 };
3105
3106 let Some(data) = self
3107 .read_state_record(storage.as_ref(), storage_keys::SESSION_STATES, peer_id)
3108 .map_err(|e| {
3109 Error::Other(format!(
3110 "Failed to load session state for {}: {}",
3111 peer_id, e
3112 ))
3113 })?
3114 else {
3115 return Ok(None);
3116 };
3117
3118 let state = serde_json::from_slice::<SessionState>(&data).map_err(|e| {
3119 Error::Other(format!(
3120 "Failed to deserialize session state for {}: {}",
3121 peer_id, e
3122 ))
3123 })?;
3124
3125 Ok(Some(state))
3126 }
3127
3128 /// Persists session state for a single peer key.
3129 pub(crate) fn persist_session_state(
3130 &self,
3131 peer_id: &str,
3132 new_state: SessionState,
3133 source_event: &str,
3134 ) -> Result<()> {
3135 let Some(storage) = &self.protocol_state_storage else {
3136 return Err(Error::MlsNotInitialized);
3137 };
3138
3139 let encoded = serde_json::to_vec(&new_state).map_err(|e| {
3140 Error::Serialization(format!("Failed to serialize session state: {}", e))
3141 })?;
3142 self.write_state_record(
3143 storage.as_ref(),
3144 storage_keys::SESSION_STATES,
3145 peer_id,
3146 &encoded,
3147 )
3148 .map_err(|e| {
3149 Error::Other(format!(
3150 "Failed to persist session state for {}: {}",
3151 peer_id, e
3152 ))
3153 })?;
3154
3155 if matches!(new_state, SessionState::Confirmed) {
3156 info!(
3157 event = "confirmation_persisted",
3158 session_or_group_id = %peer_id,
3159 previous_state = "Pending",
3160 new_state = "Confirmed",
3161 source_event = %source_event,
3162 "confirmation_persisted"
3163 );
3164 }
3165
3166 Ok(())
3167 }
3168
3169 pub(crate) fn clear_session_state_entry(&self, peer_id: &str) -> Result<()> {
3170 let Some(storage) = &self.protocol_state_storage else {
3171 return Ok(());
3172 };
3173 storage
3174 .delete(storage_keys::SESSION_STATES, peer_id)
3175 .map_err(|e| {
3176 Error::Other(format!(
3177 "Failed to clear session state for {}: {}",
3178 peer_id, e
3179 ))
3180 })
3181 }
3182
3183 // ========================================================================
3184 // WELCOME LIFECYCLE PERSISTENCE
3185 // ========================================================================
3186
3187 pub(crate) fn persist_welcome_lifecycle_entry(
3188 &self,
3189 record: &WelcomeLifecycleRecord,
3190 ) -> Result<()> {
3191 let Some(storage) = &self.protocol_state_storage else {
3192 return Err(Error::MlsNotInitialized);
3193 };
3194
3195 let encoded = serde_json::to_vec(record).map_err(|e| {
3196 Error::Serialization(format!("Failed to serialize welcome lifecycle: {}", e))
3197 })?;
3198 self.write_state_record(
3199 storage.as_ref(),
3200 storage_keys::WELCOME_LIFECYCLES,
3201 &record.peer_id,
3202 &encoded,
3203 )
3204 .map_err(|e| {
3205 Error::Other(format!(
3206 "Failed to persist welcome lifecycle for {}: {}",
3207 record.peer_id, e
3208 ))
3209 })
3210 }
3211
3212 pub(crate) fn clear_welcome_lifecycle_entry(&self, peer_id: &str) -> Result<()> {
3213 let Some(storage) = &self.protocol_state_storage else {
3214 return Ok(());
3215 };
3216 storage
3217 .delete(storage_keys::WELCOME_LIFECYCLES, peer_id)
3218 .map_err(|e| {
3219 Error::Other(format!(
3220 "Failed to clear welcome lifecycle for {}: {}",
3221 peer_id, e
3222 ))
3223 })
3224 }
3225
3226 /// Brings one restored Welcome lifecycle back to a state the retry ladder
3227 /// can act on, and reports whether anything changed.
3228 ///
3229 /// Split out so the walk can persist the result **once**, non-fatally. The
3230 /// repairs used to persist inline with `?`, which made a transient
3231 /// `StoreFailed` on any one of them fail `initialize_mls` — a persistence
3232 /// failure blocking restore, where every sibling path in this module logs
3233 /// and carries on. The in-memory map is what drives retries; a repair that
3234 /// could not be written is simply re-derived from the same record next
3235 /// launch.
3236 fn repair_restored_welcome_lifecycle(
3237 peer_id: &str,
3238 record: &mut WelcomeLifecycleRecord,
3239 ) -> bool {
3240 let mut repaired = false;
3241 if matches!(
3242 record.state,
3243 WelcomeDeliveryState::Created | WelcomeDeliveryState::SendAttempted
3244 ) {
3245 record.state = WelcomeDeliveryState::Failed;
3246 record.next_retry_at = Some(Utc::now());
3247 repaired = true;
3248 warn!(
3249 event = "welcome_lifecycle_repaired",
3250 session_or_group_id = %peer_id,
3251 repair_action = "in_flight_to_failed_retry_now",
3252 state = record.state.as_str(),
3253 attempt = record.attempt,
3254 "welcome_lifecycle_repaired"
3255 );
3256 }
3257 if matches!(record.state, WelcomeDeliveryState::Failed) && record.next_retry_at.is_none() {
3258 if matches!(
3259 record.last_reason_code,
3260 Some(crate::events::WelcomeReasonCode::RetryExhausted)
3261 ) {
3262 // Only genuine retry exhaustion (a present carrier that kept
3263 // failing) is terminal here. A stale TTL alone must NOT expire a
3264 // no-carrier Welcome on restart — the TTL clock is
3265 // carrier-relative and is refreshed below.
3266 record.state = WelcomeDeliveryState::Expired;
3267 warn!(
3268 event = "welcome_lifecycle_repaired",
3269 session_or_group_id = %peer_id,
3270 repair_action = "failed_no_retry_to_expired",
3271 state = record.state.as_str(),
3272 attempt = record.attempt,
3273 "welcome_lifecycle_repaired"
3274 );
3275 } else {
3276 // Recover from partial-crash write where Failed was persisted
3277 // without a retry schedule.
3278 record.next_retry_at = Some(Utc::now());
3279 warn!(
3280 event = "welcome_lifecycle_repaired",
3281 session_or_group_id = %peer_id,
3282 repair_action = "failed_no_retry_to_failed_retry_now",
3283 state = record.state.as_str(),
3284 attempt = record.attempt,
3285 "welcome_lifecycle_repaired"
3286 );
3287 }
3288 repaired = true;
3289 }
3290 // The TTL clock is carrier-relative: a Welcome must not be restored
3291 // already-expired after an offline period. Restart the window for any
3292 // non-terminal lifecycle whose TTL has lapsed so it gets a fresh chance
3293 // once a carrier (or the peer) reappears.
3294 if matches!(record.state, WelcomeDeliveryState::Failed) && record.expires_at <= Utc::now() {
3295 record.expires_at = Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
3296 repaired = true;
3297 warn!(
3298 event = "welcome_lifecycle_repaired",
3299 session_or_group_id = %peer_id,
3300 repair_action = "ttl_refreshed_carrier_relative",
3301 state = record.state.as_str(),
3302 attempt = record.attempt,
3303 "welcome_lifecycle_repaired"
3304 );
3305 }
3306 if matches!(
3307 record.state,
3308 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
3309 ) && record.next_retry_at.is_some()
3310 {
3311 record.next_retry_at = None;
3312 repaired = true;
3313 warn!(
3314 event = "welcome_lifecycle_repaired",
3315 session_or_group_id = %peer_id,
3316 repair_action = "terminal_clear_retry_schedule",
3317 state = record.state.as_str(),
3318 attempt = record.attempt,
3319 "welcome_lifecycle_repaired"
3320 );
3321 }
3322 repaired
3323 }
3324
3325 pub(crate) fn restore_welcome_lifecycles(
3326 &mut self,
3327 allowance: &mut PruneAllowance,
3328 ) -> Result<()> {
3329 self.welcome_lifecycles.clear();
3330 let Some(storage) = &self.protocol_state_storage else {
3331 return Ok(());
3332 };
3333
3334 let peers = Self::list_state_keys(storage.as_ref(), storage_keys::WELCOME_LIFECYCLES)
3335 .map_err(|e| Error::Other(format!("Failed to list welcome lifecycles: {}", e)))?;
3336 let listed = peers.len();
3337
3338 // Bounded like every other container-listed walk: a record that will not
3339 // decode is dropped, and that drop is a provider delete with a directory
3340 // flush behind it. The tail waits for a later launch, which is safe for
3341 // the same reason it is safe for the cache prunes — dropping a Welcome
3342 // lifecycle is recoverable, and re-reading one costs a parse. Draws on
3343 // the launch-wide advisory pool, so this walk and the three cache walks
3344 // add up against one allowance.
3345 let mut budget = allowance.refusing();
3346 let mut unavailable = 0usize;
3347 for peer_id in peers.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
3348 let mut record =
3349 match self.load_welcome_lifecycle_for_restore(&peer_id, Some(&mut budget)) {
3350 RestorableRecord::Present(record) => record,
3351 RestorableRecord::Absent => continue,
3352 // Left on disk for a later launch. Skipping one peer costs a
3353 // Welcome retry this session; failing the whole restore —
3354 // which propagating here used to do — costs the install
3355 // every send it would make, on every launch, for as long as
3356 // that one record stays unreadable.
3357 RestorableRecord::Unavailable => {
3358 unavailable += 1;
3359 continue;
3360 }
3361 };
3362 if Self::repair_restored_welcome_lifecycle(&peer_id, &mut record) {
3363 if let Err(e) = self.persist_welcome_lifecycle_entry(&record) {
3364 warn!(
3365 session_or_group_id = %peer_id,
3366 error = %e,
3367 "Failed to persist a repaired Welcome lifecycle; the repair holds in \
3368 memory and is re-derived on the next launch"
3369 );
3370 }
3371 }
3372 self.welcome_lifecycles.insert(peer_id.clone(), record);
3373 info!(
3374 event = "welcome_lifecycle_restored",
3375 session_or_group_id = %peer_id,
3376 "welcome_lifecycle_restored"
3377 );
3378 }
3379
3380 if unavailable > 0 {
3381 warn!(
3382 unavailable,
3383 "Welcome lifecycle records could not be read this session; left on disk for a \
3384 later launch"
3385 );
3386 }
3387 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
3388 warn!(
3389 listed,
3390 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
3391 "Welcome lifecycle store listed more peers than any legitimate run can produce; ignoring the tail"
3392 );
3393 }
3394 if budget.exhausted {
3395 warn!(
3396 deleted = budget.spent,
3397 budget = MAX_RESTORE_PRUNE_DELETES,
3398 "Welcome lifecycle restore hit its share of the launch delete budget; the rest is left on disk for a later launch"
3399 );
3400 }
3401
3402 Ok(())
3403 }
3404
3405 // ========================================================================
3406 // OUTBOX PERSISTENCE
3407 // ========================================================================
3408
3409 /// Persists a single outbox entry to storage, keyed by message id.
3410 ///
3411 /// Best-effort and infallible: the send path cannot propagate storage
3412 /// errors, so a failed write is logged and swallowed (the message still
3413 /// lives in the in-memory outbox and will retry; it just won't survive a
3414 /// restart). No-ops when persistence is not configured or when the entry
3415 /// belongs to the media outbox — file transfers are not persisted and
3416 /// resurrected chunks could never complete, so we never write them.
3417 pub(crate) fn persist_outbox_entry(&self, entry: &OutboxEntry) {
3418 let Some(storage) = &self.protocol_state_storage else {
3419 return;
3420 };
3421 if Self::is_media_outbox_message(&entry.message) {
3422 return;
3423 }
3424 match serde_json::to_vec(entry) {
3425 Ok(data) => {
3426 if let Err(e) = self.write_state_record(
3427 storage.as_ref(),
3428 storage_keys::OUTBOX,
3429 &entry.message.id.as_str(),
3430 &data,
3431 ) {
3432 warn!(message_id = %entry.message.id, error = %e, "Failed to persist outbox entry");
3433 }
3434 }
3435 Err(e) => {
3436 warn!(message_id = %entry.message.id, error = %e, "Failed to serialize outbox entry");
3437 }
3438 }
3439 }
3440
3441 /// Removes a persisted outbox entry from storage. Best-effort: a media
3442 /// message id is never persisted, so deleting it is a harmless no-op.
3443 pub(crate) fn clear_outbox_entry_from_storage(&self, message_id: &MessageId) {
3444 let Some(storage) = &self.protocol_state_storage else {
3445 return;
3446 };
3447 if let Err(e) = storage.delete(storage_keys::OUTBOX, &message_id.as_str()) {
3448 warn!(message_id = %message_id, error = %e, "Failed to clear persisted outbox entry");
3449 }
3450 }
3451
3452 /// Restores the store-and-forward outbox from storage on startup.
3453 ///
3454 /// Merges persisted entries into `self.outbox` — it is *not* cleared first.
3455 /// An entry queued before persistence was enabled lives only in memory and,
3456 /// if it succeeded at the transport but is awaiting an ACK, is not in the
3457 /// retry queue either; clearing would strand it with no recovery path (the
3458 /// ACK-timeout path drops a message that is missing from the outbox). Where
3459 /// an id exists in both, storage is authoritative and overwrites.
3460 ///
3461 /// The retry queue and ACK manager start empty, so every restored entry
3462 /// lands in the "stranded" state that [`Self::flush_outbox_all`] already
3463 /// recovers — a flush on `start()` re-drives delivery.
3464 ///
3465 /// Recovery rules, mirroring the other restore paths:
3466 /// - corrupted entries are dropped from storage and skipped;
3467 /// - any stray media entry is dropped (they must never be resurrected);
3468 /// - the total is pruned to `MAX_OUTBOX_ENTRIES`, keeping the newest by
3469 /// `last_sent_at`;
3470 /// - an entry whose total age (from `first_sent_at`) exceeds
3471 /// [`OUTBOX_ABSOLUTE_LIFETIME_FACTOR`] × the outbox lifetime is dropped
3472 /// terminally with a `message_failed`, so repeated restarts can't
3473 /// re-grant a fresh window forever;
3474 /// - otherwise the TTL clock is carrier-relative: an entry whose
3475 /// `last_sent_at` has already lapsed the outbox lifetime is refreshed
3476 /// rather than restored already-expired, so it gets a fresh delivery
3477 /// window once a carrier reappears (mirrors the Welcome lifecycle
3478 /// repair). This runs *after* the prune, so a refreshed (now-stamped)
3479 /// clock can't sort as the newest and crowd genuinely-fresh entries out
3480 /// of the kept set;
3481 /// - pre-existing in-memory entries not yet in storage are persisted, so
3482 /// memory and storage are consistent once restore returns.
3483 ///
3484 /// Budgeted by [`MAX_RESTORE_PRUNE_DELETES`] the way
3485 /// [`Self::restore_pending_messages`] is, and for the same reason: every
3486 /// delete here is paired with a terminal `message_failed`, so an individual
3487 /// one can never be *refused* — skipping the delete while settling would
3488 /// settle an id whose record the next launch restores and re-drives, and
3489 /// skipping both would drop the entry from memory while leaving the
3490 /// application holding an id nothing ever resolves. What the pairing does
3491 /// not forbid is **stopping between records**, which is what this walk does
3492 /// — and what its two post-walk prunes do between *entries*.
3493 ///
3494 /// [`OUTBOX_RESTORE_KEY_CAP`] alone was once argued to be enough here, on
3495 /// the grounds that it caps the walk near 1.5k deletes. Two things were
3496 /// wrong with that. The number came from the capacity prune, not the walk:
3497 /// the outbox is a *sealed* category, so the wrong-length record-key branch
3498 /// of [`Self::restore_or_init_state_record_key`] makes every entry on the
3499 /// install unopenable at once and each one takes the reader's drop — the
3500 /// full walk bound, in device barriers, on the synchronous boot path. And
3501 /// the number it *did* describe was never bounded either: the capacity
3502 /// drain and the absolute-lifetime drop run after the walk, on entries it
3503 /// admitted, so a store whose records all open cleanly reaches them with
3504 /// the pool untouched. All three now draw on the same pool and stop at an
3505 /// entry boundary; see [`MAX_RESTORE_PRUNE_DELETES`].
3506 ///
3507 /// It draws on its **own** pool rather than the launch-wide advisory one:
3508 /// an outbox record left unwalked is one the application holds a *live* id
3509 /// for, so deferring it defers delivery, and that must not be a consequence
3510 /// of an unrelated key-package flood spending the shared allowance first.
3511 /// No freeze is needed for the tail — outbox records are keyed per message
3512 /// id, so a later write only ever touches a different key.
3513 ///
3514 /// `allowance` is this walk's own pool, built by the launch — see
3515 /// [`PruneAllowance::pool`].
3516 pub(crate) fn restore_outbox(&mut self, allowance: &mut PruneAllowance) -> Result<()> {
3517 // Cloned rather than borrowed: the loop below both reads through this
3518 // handle and calls `&mut self` settlement paths, so holding a borrow of
3519 // `self` across it is what forced the awkward per-iteration re-fetch
3520 // this replaced.
3521 let Some(storage) = self.protocol_state_storage.clone() else {
3522 return Ok(());
3523 };
3524
3525 let message_ids = Self::list_state_keys(storage.as_ref(), storage_keys::OUTBOX)
3526 .map_err(|e| Error::Other(format!("Failed to list outbox entries: {}", e)))?;
3527
3528 let lifetime_ms = self.config.reliability.retry.outbox_max_lifetime_ms;
3529 let listed = message_ids.len();
3530
3531 let mut restored: Vec<OutboxEntry> = Vec::new();
3532 // Record keys the app was told were queued but that cannot be
3533 // recovered. Unlike the pending queue, an outbox record is keyed *by*
3534 // its message id, so each loss is individually settleable without
3535 // opening it — kept as the raw key so one that does not parse as a
3536 // `MessageId` still gets a diagnostic instead of silence.
3537 let mut unrecoverable: Vec<String> = Vec::new();
3538 // Settlement-paired like the pending walk, and budgeted the same way:
3539 // counting, never refusing, stopping at a record boundary. Its own pool
3540 // rather than the advisory one, because starving this walk defers
3541 // *delivery* of every message the app still holds a live id for.
3542 //
3543 // The walk bound alone is not the ceiling it was argued to be. The
3544 // outbox is a sealed category, so a regenerated record key makes every
3545 // entry on the install fail to open at once and each one takes the
3546 // reader's drop below — `OUTBOX_RESTORE_KEY_CAP` device barriers, on the
3547 // synchronous boot path, with nothing counting them. That is verbatim
3548 // the case that put a budget on `restore_pending_messages`.
3549 //
3550 // No freeze is needed for the tail this stops at, unlike the pending
3551 // walk: outbox records are keyed per message id, so every later write
3552 // touches a different key and an unwalked record is simply restored and
3553 // re-driven on the next launch.
3554 //
3555 // The pool is passed in rather than built here — see
3556 // `PruneAllowance::pool`.
3557 let mut budget = allowance.counting();
3558 let mut prune_bound_reached = false;
3559 for message_id in message_ids.into_iter().take(OUTBOX_RESTORE_KEY_CAP) {
3560 if budget.is_spent() {
3561 prune_bound_reached = true;
3562 break;
3563 }
3564 let data = match self.read_state_record_detailed_budgeted(
3565 storage.as_ref(),
3566 storage_keys::OUTBOX,
3567 &message_id,
3568 Some(&mut budget),
3569 ) {
3570 Ok(StateRecord::Present(data)) => data,
3571 Ok(StateRecord::Missing) => continue,
3572 Ok(StateRecord::Unreadable) => {
3573 warn!(message_id = %message_id, "Dropping unreadable outbox entry");
3574 unrecoverable.push(message_id);
3575 continue;
3576 }
3577 // Still on disk and probably intact — the record key is not
3578 // loaded, or the backend refused this read. Settling it as
3579 // failed would be a terminal answer the next launch overturns
3580 // by restoring the entry and re-driving delivery, so the app
3581 // would see `message_failed` and then a delivery (plus a second
3582 // copy if the user re-sent by hand, which mints a new id that
3583 // dedup cannot collapse). Leave it be.
3584 Ok(StateRecord::Unavailable) | Err(_) => {
3585 warn!(
3586 message_id = %message_id,
3587 "Outbox entry could not be read this session; leaving it in place"
3588 );
3589 continue;
3590 }
3591 };
3592
3593 let entry = match serde_json::from_slice::<OutboxEntry>(&data) {
3594 Ok(entry) => entry,
3595 Err(e) => {
3596 warn!(message_id = %message_id, error = %e, "Dropping corrupted outbox entry");
3597 // Charged, never refused: the settlement below is already
3598 // owed, so the record has to go with it.
3599 budget.claim();
3600 self.delete_outbox_key(&message_id);
3601 unrecoverable.push(message_id);
3602 continue;
3603 }
3604 };
3605
3606 // A media entry should never have been persisted; drop any that
3607 // slipped in (e.g. from an older build) so it can't be resurrected.
3608 if Self::is_media_outbox_message(&entry.message) {
3609 warn!(message_id = %message_id, "Dropping persisted media outbox entry");
3610 budget.claim();
3611 self.delete_outbox_key(&message_id);
3612 continue;
3613 }
3614
3615 restored.push(entry);
3616 }
3617
3618 // Drop entries past the absolute lifetime cap before anything else:
3619 // the carrier-relative refresh below would otherwise re-grant them a
3620 // fresh window on every restart, indefinitely. This is a terminal
3621 // drop — emit `message_failed` like the in-process expiry does.
3622 let absolute_lifetime_ms =
3623 lifetime_ms.saturating_mul(crate::constants::OUTBOX_ABSOLUTE_LIFETIME_FACTOR as u64);
3624 let now = Utc::now();
3625 let mut absolutely_expired: Vec<OutboxEntry> = Vec::new();
3626 restored.retain(|entry| {
3627 if lifetime_expired(now, entry.last_sent_at, lifetime_ms)
3628 && lifetime_expired(now, entry.first_sent_at, absolute_lifetime_ms)
3629 {
3630 absolutely_expired.push(entry.clone());
3631 return false;
3632 }
3633 true
3634 });
3635 // Counted and never refused *mid-entry* — this delete is inseparable
3636 // from the `message_failed` that pairs with it — but stopped *between*
3637 // entries, exactly as the walk stops between records.
3638 //
3639 // The gate is load-bearing rather than tidy. This loop and the capacity
3640 // drain below act on entries the walk *admitted*, so a store whose
3641 // records all open cleanly reaches them with the pool untouched and
3642 // `restored` bounded only by `OUTBOX_RESTORE_KEY_CAP`. Ungated, that is
3643 // up to that many device barriers in a single launch — a device offline
3644 // past the absolute lifetime with a full outbox, which is the *ordinary*
3645 // over-capacity case rather than the tampered one.
3646 //
3647 // An entry the pool cannot fund is dropped from memory and left on disk
3648 // **unsettled**. That is what makes stopping safe: nothing has told the
3649 // application anything about it, so a later launch restores it, re-ages
3650 // it, and owns both halves of the settlement then — the same deferral
3651 // the walk's own early break relies on.
3652 let mut expiry_settlements: Vec<Event> = Vec::new();
3653 for entry in &absolutely_expired {
3654 if budget.is_spent() {
3655 prune_bound_reached = true;
3656 break;
3657 }
3658 budget.claim();
3659 self.delete_outbox_key(&entry.message.id.as_str());
3660 info!(
3661 event = "outbox_entry_dropped",
3662 message_id = %entry.message.id,
3663 repair_action = "absolute_lifetime_exceeded",
3664 "outbox_entry_dropped"
3665 );
3666 expiry_settlements.push(Event::message_failed(
3667 entry.message.id.clone(),
3668 "Outbox lifetime exceeded".to_string(),
3669 entry.attempt_count,
3670 ));
3671 }
3672 // Batched: this is one of the two loops `settle_restored_message_failures`
3673 // exists for. A store past its cap drives it into the hundreds, and the
3674 // per-event form takes the shared-state lock once each.
3675 self.settle_restored_message_failures(expiry_settlements);
3676
3677 // Prune to capacity BEFORE refreshing TTLs, keeping the newest by
3678 // last_sent_at. Delete the pruned overflow from storage so it can't
3679 // linger and be re-restored. Ordering matters: refreshing a lapsed
3680 // clock stamps it with `now`, which would otherwise sort it as the
3681 // newest and crowd genuinely-fresh entries out of the kept set.
3682 if restored.len() > MAX_OUTBOX_ENTRIES {
3683 restored.sort_by_key(|e| std::cmp::Reverse(e.last_sent_at));
3684 // `drain` removes its whole range when the iterator is dropped, so
3685 // breaking early still takes every over-cap entry out of memory.
3686 // That is what keeps the cap absolute: deferring a *delete* must
3687 // never defer the cap itself, or an over-cap store would re-inflate
3688 // memory on boot. The entries the pool could not fund are left on
3689 // disk unsettled and re-capped by a later launch, exactly like the
3690 // tail the walk never reached.
3691 //
3692 // Unbudgeted, this drain alone issues
3693 // `OUTBOX_RESTORE_KEY_CAP - MAX_OUTBOX_ENTRIES` deletes in one
3694 // launch — the very figure this walk's old budget exemption was
3695 // argued from. Counted, never refused mid-entry, stopped between
3696 // entries: see the absolute-expiry drop above.
3697 let mut capacity_settlements: Vec<Event> = Vec::new();
3698 for entry in restored.drain(MAX_OUTBOX_ENTRIES..) {
3699 if budget.is_spent() {
3700 prune_bound_reached = true;
3701 break;
3702 }
3703 budget.claim();
3704 self.delete_outbox_key(&entry.message.id.as_str());
3705 // Terminal, like the pending queue's capacity eviction: the app
3706 // holds this id and nothing will ever resolve it otherwise.
3707 capacity_settlements.push(Event::message_failed(
3708 entry.message.id.clone(),
3709 "Outbox capacity exceeded".to_string(),
3710 entry.attempt_count,
3711 ));
3712 }
3713 // Batched for the same reason as the expiry settlements above.
3714 self.settle_restored_message_failures(capacity_settlements);
3715 }
3716
3717 self.settle_restored_message_failures(unrecoverable.iter().map(|key| {
3718 Self::unrecoverable_message_settlement(
3719 key,
3720 "Outbox entry could not be recovered from protocol-state storage",
3721 0,
3722 )
3723 }));
3724 if listed > OUTBOX_RESTORE_KEY_CAP {
3725 warn!(
3726 listed,
3727 cap = OUTBOX_RESTORE_KEY_CAP,
3728 "Outbox store listed more entries than any legitimate run can produce; ignoring the tail"
3729 );
3730 }
3731 // Covers all three places this walk can stop: the read loop, the
3732 // absolute-expiry drop, and the capacity drain. `budget.exhausted` is
3733 // unreachable while each of those checks `is_spent()` before claiming —
3734 // it is ORed in so a future ungated claim still surfaces rather than
3735 // spending the pool in silence.
3736 if prune_bound_reached || budget.exhausted {
3737 warn!(
3738 deleted = budget.spent,
3739 budget = MAX_RESTORE_PRUNE_DELETES,
3740 "Outbox restore spent its delete budget; the remaining entries are left on disk \
3741 and restored on a later launch"
3742 );
3743 }
3744
3745 // Carrier-relative TTL: refresh any entry already past the outbox
3746 // lifetime so it survives the first cleanup tick and gets a fresh chance
3747 // once a carrier appears. Collect the refreshed clones so the repair can
3748 // be re-persisted below, once the mutable borrow of `restored` is gone.
3749 let mut refreshed: Vec<OutboxEntry> = Vec::new();
3750 for entry in &mut restored {
3751 if lifetime_expired(now, entry.last_sent_at, lifetime_ms) {
3752 entry.last_sent_at = now;
3753 refreshed.push(entry.clone());
3754 info!(
3755 event = "outbox_entry_restored",
3756 message_id = %entry.message.id,
3757 repair_action = "ttl_refreshed_carrier_relative",
3758 "outbox_entry_restored"
3759 );
3760 }
3761 }
3762
3763 // Pre-existing in-memory entries not backed by storage (queued before
3764 // persistence was enabled) must be persisted too, so they survive the
3765 // next restart and memory/storage stay consistent after restore.
3766 let restored_ids: std::collections::HashSet<String> =
3767 restored.iter().map(|e| e.message.id.as_str()).collect();
3768 let orphans: Vec<OutboxEntry> = self
3769 .outbox
3770 .values()
3771 .filter(|e| !restored_ids.contains(&e.message.id.as_str()))
3772 .cloned()
3773 .collect();
3774
3775 let count = restored.len();
3776 for entry in restored {
3777 self.outbox.insert(entry.message.id.clone(), entry);
3778 }
3779 for entry in &refreshed {
3780 self.persist_outbox_entry(entry);
3781 }
3782 for entry in &orphans {
3783 self.persist_outbox_entry(entry);
3784 }
3785 if count > 0 {
3786 info!(count = count, "Restored outbox entries from storage");
3787 }
3788
3789 Ok(())
3790 }
3791
3792 /// Deletes an outbox key from storage without the media/no-storage guards
3793 /// of [`Self::clear_outbox_entry_from_storage`] — used inside
3794 /// [`Self::restore_outbox`], which already holds a storage handle and
3795 /// operates on raw persisted keys.
3796 fn delete_outbox_key(&self, message_id: &str) {
3797 if let Some(storage) = &self.protocol_state_storage {
3798 if let Err(e) = storage.delete(storage_keys::OUTBOX, message_id) {
3799 warn!(message_id = %message_id, error = %e, "Failed to delete outbox key");
3800 }
3801 }
3802 }
3803
3804 // ========================================================================
3805 // MEDIA TRANSFER DESCRIPTOR PERSISTENCE
3806 // ========================================================================
3807
3808 /// Persists a media transfer descriptor (never chunk bytes) to storage.
3809 ///
3810 /// Best-effort and infallible, like [`Self::persist_outbox_entry`]: a
3811 /// failed write only costs the crash-recovery signal, not the transfer.
3812 pub(crate) fn persist_media_descriptor(&self, descriptor: &MediaTransferDescriptor) {
3813 let Some(storage) = &self.protocol_state_storage else {
3814 return;
3815 };
3816 match serde_json::to_vec(descriptor) {
3817 Ok(data) => {
3818 if let Err(e) = self.write_state_record(
3819 storage.as_ref(),
3820 storage_keys::MEDIA_DESCRIPTORS,
3821 &descriptor.file_id,
3822 &data,
3823 ) {
3824 warn!(file_id = %descriptor.file_id, error = %e, "Failed to persist media descriptor");
3825 }
3826 }
3827 Err(e) => {
3828 warn!(file_id = %descriptor.file_id, error = %e, "Failed to serialize media descriptor");
3829 }
3830 }
3831 }
3832
3833 /// Removes a persisted media transfer descriptor (and any restored copy).
3834 ///
3835 /// Called wherever the in-memory transfer is removed — completion, abort,
3836 /// stale sweep — and when a restored descriptor is consumed by a resend,
3837 /// so a descriptor only ever survives into a restore when the process
3838 /// died mid-transfer.
3839 pub(crate) fn remove_media_descriptor(&mut self, file_id: &str) {
3840 self.restored_media_descriptors.remove(file_id);
3841 let Some(storage) = &self.protocol_state_storage else {
3842 return;
3843 };
3844 if let Err(e) = storage.delete(storage_keys::MEDIA_DESCRIPTORS, file_id) {
3845 warn!(file_id = %file_id, error = %e, "Failed to clear persisted media descriptor");
3846 }
3847 }
3848
3849 /// Restores persisted media transfer descriptors on startup.
3850 ///
3851 /// Recovery rules mirror [`Self::restore_outbox`]:
3852 /// - a record that will not open, or that the store itself calls corrupt,
3853 /// is dropped and skipped;
3854 /// - so is one whose bytes are not a descriptor;
3855 /// - entries older than the outbox lifetime are dropped — the app has
3856 /// long settled that message's fate, a resend signal would be noise;
3857 /// - the total is pruned to `MAX_MEDIA_DESCRIPTORS`, keeping the newest
3858 /// by `queued_at` (overflow deleted from storage).
3859 ///
3860 /// **All four** of those deletes share one [`MAX_RESTORE_PRUNE_DELETES`]
3861 /// budget, including the first — which this walk does not issue itself, so
3862 /// it is the one that went uncounted: it happens inside
3863 /// [`Self::read_state_record_detailed`], which is why the budget is handed
3864 /// to the reader rather than only claimed around the explicit calls below.
3865 /// Descriptors are a sealed category, so a regenerated record key makes
3866 /// *every* record on the install take that path at once.
3867 ///
3868 /// A descriptor is advisory, so a record the budget spared is simply
3869 /// re-walked and dropped on a later launch.
3870 ///
3871 /// The survivors are parked in `restored_media_descriptors`; `start()`
3872 /// emits one `MediaResendRequired` each once the event pipeline is live,
3873 /// leaving entries parked until a same-`file_id` resend consumes them or
3874 /// the restore TTL prunes them.
3875 pub(crate) fn restore_media_descriptors(
3876 &mut self,
3877 allowance: &mut PruneAllowance,
3878 ) -> Result<()> {
3879 // Cloned rather than borrowed, like `restore_outbox`: the walk below
3880 // reads through this handle while holding a mutable borrow of the
3881 // prune budget and calling `&self` delete helpers.
3882 let Some(storage) = self.protocol_state_storage.clone() else {
3883 return Ok(());
3884 };
3885
3886 let file_ids = Self::list_state_keys(storage.as_ref(), storage_keys::MEDIA_DESCRIPTORS)
3887 .map_err(|e| Error::Other(format!("Failed to list media descriptors: {}", e)))?;
3888
3889 let lifetime_ms = self.config.reliability.retry.outbox_max_lifetime_ms;
3890 let now = Utc::now();
3891
3892 let mut restored: Vec<MediaTransferDescriptor> = Vec::new();
3893 // Every delete this walk causes — unreadable, unparseable, expired,
3894 // over-cap — is budgeted together: a descriptor is purely advisory (the
3895 // app re-initiates the transfer), so a record left on disk one launch
3896 // longer costs nothing, while the device barrier each delete carries is
3897 // the most expensive thing this walk can do. Skipped records are simply
3898 // re-walked next launch and dropped then. "Causes" rather than
3899 // "issues": the first of the four happens inside the reader, which is
3900 // why the budget is handed to it below. Draws on the launch-wide
3901 // advisory pool it shares with the two cache walks and the Welcome
3902 // lifecycle walk.
3903 let mut budget = allowance.refusing();
3904 // Bounded, but deliberately by the wide ceiling rather than a multiple
3905 // of `MAX_MEDIA_DESCRIPTORS`: that cap is applied here, on restore, and
3906 // nowhere on the insert path, so a long session can legitimately leave
3907 // more descriptors on disk than the cap. A tight prefix would keep the
3908 // wrong ones (the survivors are chosen by `queued_at` among what was
3909 // walked) and strand the rest forever, since nothing else deletes them.
3910 // A descriptor loss is advisory anyway — the app re-initiates the
3911 // transfer — so there is nothing to settle here; only the walk itself
3912 // needs a ceiling.
3913 for file_id in file_ids.into_iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
3914 // The reader takes the budget because it is the reader that deletes
3915 // a record it cannot return — absent, unreadable, and
3916 // unreadable-this-session are all "nothing to park" here, so this
3917 // walk only has to distinguish them from a record it can decode.
3918 let data = match self.read_state_record_detailed_budgeted(
3919 storage.as_ref(),
3920 storage_keys::MEDIA_DESCRIPTORS,
3921 &file_id,
3922 Some(&mut budget),
3923 ) {
3924 Ok(StateRecord::Present(data)) => data,
3925 Ok(StateRecord::Missing | StateRecord::Unreadable | StateRecord::Unavailable)
3926 | Err(_) => continue,
3927 };
3928
3929 let descriptor = match serde_json::from_slice::<MediaTransferDescriptor>(&data) {
3930 Ok(descriptor) => descriptor,
3931 Err(e) => {
3932 warn!(file_id = %file_id, error = %e, "Dropping corrupted media descriptor");
3933 if budget.claim() {
3934 self.delete_media_descriptor_key(&file_id);
3935 }
3936 continue;
3937 }
3938 };
3939
3940 if lifetime_expired(now, descriptor.queued_at, lifetime_ms) {
3941 debug!(file_id = %file_id, "Dropping expired media descriptor");
3942 if budget.claim() {
3943 self.delete_media_descriptor_key(&file_id);
3944 }
3945 continue;
3946 }
3947
3948 restored.push(descriptor);
3949 }
3950
3951 if restored.len() > MAX_MEDIA_DESCRIPTORS {
3952 restored.sort_by_key(|d| std::cmp::Reverse(d.queued_at));
3953 for descriptor in restored.drain(MAX_MEDIA_DESCRIPTORS..) {
3954 // Dropped from the parked set either way: the cap is what this
3955 // build will announce, and a record the budget spared is
3956 // re-walked (and re-capped) on the next launch.
3957 if budget.claim() {
3958 self.delete_media_descriptor_key(&descriptor.file_id);
3959 }
3960 }
3961 }
3962
3963 if budget.exhausted {
3964 warn!(
3965 deleted = budget.spent,
3966 budget = MAX_RESTORE_PRUNE_DELETES,
3967 "Media descriptor prune hit its share of the launch delete budget; the rest is left on disk for a later launch"
3968 );
3969 }
3970
3971 let count = restored.len();
3972 for descriptor in restored {
3973 self.restored_media_descriptors
3974 .insert(descriptor.file_id.clone(), descriptor);
3975 }
3976 if count > 0 {
3977 info!(
3978 count = count,
3979 "Restored media transfer descriptors from storage"
3980 );
3981 }
3982
3983 Ok(())
3984 }
3985
3986 /// Deletes a media descriptor key from storage (restore-internal, mirrors
3987 /// [`Self::delete_outbox_key`]).
3988 fn delete_media_descriptor_key(&self, file_id: &str) {
3989 if let Some(storage) = &self.protocol_state_storage {
3990 if let Err(e) = storage.delete(storage_keys::MEDIA_DESCRIPTORS, file_id) {
3991 warn!(file_id = %file_id, error = %e, "Failed to delete media descriptor key");
3992 }
3993 }
3994 }
3995
3996 // ========================================================================
3997 // BLOCKED USERS PERSISTENCE
3998 // ========================================================================
3999
4000 /// Persists a blocked user entry to storage.
4001 pub(crate) fn persist_blocked_user(&self, user_id: &str) {
4002 let Some(storage) = &self.protocol_state_storage else {
4003 return;
4004 };
4005 if let Err(e) =
4006 self.write_state_record(storage.as_ref(), storage_keys::BLOCKED_USERS, user_id, &[])
4007 {
4008 warn!(user_id = %user_id, error = %e, "Failed to persist blocked user");
4009 }
4010 }
4011
4012 /// Deletes a blocked user entry from storage.
4013 pub(crate) fn delete_blocked_user(&self, user_id: &str) {
4014 let Some(storage) = &self.protocol_state_storage else {
4015 return;
4016 };
4017 if let Err(e) = storage.delete(storage_keys::BLOCKED_USERS, user_id) {
4018 warn!(user_id = %user_id, error = %e, "Failed to delete blocked user from storage");
4019 }
4020 }
4021
4022 /// Restores blocked users from persistent storage.
4023 ///
4024 /// Skips entries with invalid user IDs (best-effort restore).
4025 ///
4026 /// A *listing* failure is not best-effort: it is indistinguishable from an
4027 /// empty store, so swallowing it would come up with an empty block list and
4028 /// tell no one — every peer the user blocked silently unblocked, from a
4029 /// transient error. That is the same outcome this branch's release notes
4030 /// call out as the reason a downgrade is not a rollback. Propagating rolls
4031 /// `initialize_mls` back instead, so the app finds out rather than running
4032 /// unprotected. Blocking is a safety control; it fails closed.
4033 pub(crate) fn restore_blocked_users(&mut self) -> Result<()> {
4034 let Some(storage) = &self.protocol_state_storage else {
4035 return Ok(());
4036 };
4037 let user_ids = Self::list_state_keys(storage.as_ref(), storage_keys::BLOCKED_USERS)
4038 .map_err(|e| Error::Other(format!("Failed to list blocked users: {}", e)))?;
4039 let listed = user_ids.len();
4040 // Two bounds, because they do two different jobs and neither implies
4041 // the other. This one bounds *work*: how many keys a single pass may
4042 // look at, like every other category walk. The `MAX_BLOCKED_USERS`
4043 // break below bounds the *set*. Only the walk bound covers the entries
4044 // that never reach the set at all — an invalid user id or a planted
4045 // self-block `continue`s without counting toward the live cap, so a
4046 // container filled with unparseable keys would otherwise cost one
4047 // iteration and one `warn!` line each, unbounded, on every launch.
4048 for user_id in user_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
4049 if offline_protocol_core::UserId::new(user_id).is_err() {
4050 warn!(user_id = %user_id, "Skipping blocked user entry with invalid user ID");
4051 continue;
4052 }
4053 // The live path refuses to block your own id; restore has to refuse
4054 // it too, or a planted record blocks the local user — which
4055 // `block_user` would never have produced.
4056 if user_id.as_str() == self.local_id {
4057 warn!("Skipping blocked user entry naming the local user");
4058 continue;
4059 }
4060 // Bounded by the *live* cap, not the restore-walk cap.
4061 //
4062 // These are two different numbers for two different jobs, and using
4063 // the walk bound here let them disagree by 6384 entries.
4064 // `MAX_RESTORE_KEYS_PER_CATEGORY` bounds work — how many keys one
4065 // pass may look at. `MAX_BLOCKED_USERS` bounds the block list
4066 // itself, and `block_user` enforces it on every insert. Restoring
4067 // past it left the set in a state the live path cannot reach, where
4068 // every subsequent `block_user` fails with "limit reached" until the
4069 // user manually unblocks. Since this category is never consumed by
4070 // restore, planted records persist across every launch — so an
4071 // attacker who can write the container could permanently disable
4072 // blocking by adding markers, without unblocking anyone.
4073 //
4074 // Stopping at the cap keeps the blocks already restored, which is
4075 // the fail-closed direction: an over-full store still blocks the
4076 // first MAX_BLOCKED_USERS peers it names.
4077 if self.blocked_users.len() >= MAX_BLOCKED_USERS {
4078 warn!(
4079 listed,
4080 cap = MAX_BLOCKED_USERS,
4081 "Blocked-user store holds more entries than the live cap admits; \
4082 restoring the first {} and ignoring the rest",
4083 MAX_BLOCKED_USERS
4084 );
4085 break;
4086 }
4087 self.blocked_users.insert(user_id.clone());
4088 }
4089 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
4090 warn!(
4091 listed,
4092 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
4093 "Blocked-user store listed more entries than any legitimate run can produce; ignoring the tail"
4094 );
4095 }
4096 if !self.blocked_users.is_empty() {
4097 info!(
4098 count = self.blocked_users.len(),
4099 "Restored blocked users from storage"
4100 );
4101 }
4102 Ok(())
4103 }
4104
4105 // ========================================================================
4106 // BOTH-CREATE OWNER GATE PERSISTENCE
4107 // ========================================================================
4108
4109 /// Persists a both-create owner-gate entry (value-less; the key is the peer).
4110 pub(crate) fn persist_both_create_awaiting_decrypt(&self, peer_id: &str) {
4111 let Some(storage) = &self.protocol_state_storage else {
4112 return;
4113 };
4114 if let Err(e) = self.write_state_record(
4115 storage.as_ref(),
4116 storage_keys::BOTH_CREATE_AWAITING_DECRYPT,
4117 peer_id,
4118 &[],
4119 ) {
4120 warn!(peer_id = %peer_id, error = %e, "Failed to persist both-create owner gate");
4121 }
4122 }
4123
4124 /// Deletes a both-create owner-gate entry once the peer has converged.
4125 pub(crate) fn delete_both_create_awaiting_decrypt(&self, peer_id: &str) {
4126 let Some(storage) = &self.protocol_state_storage else {
4127 return;
4128 };
4129 if let Err(e) = storage.delete(storage_keys::BOTH_CREATE_AWAITING_DECRYPT, peer_id) {
4130 warn!(peer_id = %peer_id, error = %e, "Failed to delete both-create owner gate");
4131 }
4132 }
4133
4134 /// Restores the both-create owner gate from storage on startup, so an owner
4135 /// that restarted mid-convergence keeps requiring a group-aware decrypt
4136 /// before confirming a still-pending peer. Stale entries for already-confirmed
4137 /// peers are harmless (confirmation short-circuits) and are cleared on the
4138 /// next confirm.
4139 pub(crate) fn restore_both_create_awaiting_decrypt(&mut self) {
4140 let Some(storage) = &self.protocol_state_storage else {
4141 return;
4142 };
4143 let peer_ids = match Self::list_state_keys(
4144 storage.as_ref(),
4145 storage_keys::BOTH_CREATE_AWAITING_DECRYPT,
4146 ) {
4147 Ok(keys) => keys,
4148 Err(e) => {
4149 warn!(error = %e, "Failed to list both-create owner gate from storage");
4150 return;
4151 }
4152 };
4153 let listed = peer_ids.len();
4154 for peer_id in peer_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) {
4155 self.both_create_awaiting_decrypt.insert(peer_id.clone());
4156 }
4157 if listed > MAX_RESTORE_KEYS_PER_CATEGORY {
4158 warn!(
4159 listed,
4160 cap = MAX_RESTORE_KEYS_PER_CATEGORY,
4161 "Both-create owner gate listed more peers than any legitimate run can produce; ignoring the tail"
4162 );
4163 }
4164 if !self.both_create_awaiting_decrypt.is_empty() {
4165 info!(
4166 count = self.both_create_awaiting_decrypt.len(),
4167 "Restored both-create owner gate from storage"
4168 );
4169 }
4170 }
4171
4172 // ========================================================================
4173 // TELEMETRY SCRUB-SECRET PERSISTENCE
4174 // ========================================================================
4175
4176 /// Loads (or, on first run, generates and persists) the per-install
4177 /// telemetry scrub secret, then installs it as the fallback secret used
4178 /// for opaque-identifier hashing when no explicit `scrub_secret` is set on
4179 /// the installed `TelemetryConfig`.
4180 ///
4181 /// This makes opaque identifiers stable across process restarts so backend
4182 /// telemetry can count distinct devices: the same device hashes to the same
4183 /// opaque id every session. Until storage is available (or if storage is
4184 /// never provided), the SDK keeps using the random per-instance fallback
4185 /// generated at construction — so this is purely an upgrade over the
4186 /// random fallback, never a regression.
4187 ///
4188 /// Secret precedence is unchanged: an explicit
4189 /// [`crate::telemetry::TelemetryConfig::with_scrub_secret`] still wins over
4190 /// this persistent fallback (see [`crate::telemetry::Scrubber::from_config`]).
4191 ///
4192 /// Idempotent across repeated initialization attempts via
4193 /// `telemetry_secret_persisted`. All
4194 /// storage failures degrade gracefully to the in-memory random fallback —
4195 /// telemetry pseudonymization must never block protocol initialization.
4196 pub(crate) fn restore_or_init_scrub_secret(&mut self) {
4197 if self.telemetry_secret_persisted {
4198 return;
4199 }
4200 let Some(storage) = &self.secure_storage else {
4201 return;
4202 };
4203
4204 let secret: [u8; 16] = match storage
4205 .load(storage_keys::SCRUB_SECRET, storage_keys::SCRUB_SECRET_ID)
4206 {
4207 Ok(Some(bytes)) if bytes.len() == 16 => {
4208 let mut secret = [0u8; 16];
4209 secret.copy_from_slice(&bytes);
4210 debug!("Restored persistent telemetry scrub secret from storage");
4211 secret
4212 }
4213 Ok(other) => {
4214 // Absent, or present but corrupt/wrong-length: generate a fresh
4215 // secret and persist it. A wrong-length blob is overwritten so a
4216 // single corrupt write does not pin every future session to the
4217 // random fallback.
4218 if other.is_some() {
4219 warn!("Persisted scrub secret had unexpected length; regenerating");
4220 }
4221 let fresh = *uuid::Uuid::new_v4().as_bytes();
4222 if let Err(e) = storage.store(
4223 storage_keys::SCRUB_SECRET,
4224 storage_keys::SCRUB_SECRET_ID,
4225 &fresh,
4226 ) {
4227 // Keep the in-memory secret for this session; next launch
4228 // will retry persistence. Opaque ids stay stable within
4229 // this process but may differ next session — strictly no
4230 // worse than the legacy random fallback.
4231 warn!(error = %e, "Failed to persist telemetry scrub secret; using session-local secret");
4232 return self.adopt_fallback_secret(fresh);
4233 }
4234 info!("Generated and persisted per-install telemetry scrub secret");
4235 fresh
4236 }
4237 Err(e) => {
4238 warn!(error = %e, "Failed to load telemetry scrub secret; keeping random fallback");
4239 return;
4240 }
4241 };
4242
4243 self.telemetry_secret_persisted = true;
4244 self.adopt_fallback_secret(secret);
4245 }
4246
4247 // ========================================================================
4248 // NOSTR SIGNING-SECRET PERSISTENCE
4249 // ========================================================================
4250
4251 /// Loads (or, on first run, generates and persists) the per-install Nostr
4252 /// signing secret and installs the derived signing key into the Nostr
4253 /// transport, replacing the ephemeral key it was constructed with.
4254 ///
4255 /// This gives the install a stable Nostr identity (event signatures,
4256 /// relay-visible pubkey) across restarts. The signing key is intentionally
4257 /// not derivable from any public identifier (SEC-M4); message addressing
4258 /// is unaffected because it uses the separate routing tag, which is
4259 /// derived from this device's address.
4260 ///
4261 /// Registering the Nostr transport *after* this has run leaves it on its
4262 /// ephemeral key — see [`Self::restore_nostr_transport_state`], which is
4263 /// how the bindings layer (whose transport cannot exist until the address
4264 /// does) gets a second pass.
4265 ///
4266 /// Idempotent across repeated initialization attempts via
4267 /// `nostr_secret_persisted`. All
4268 /// failures degrade gracefully to the construction-time ephemeral key,
4269 /// which is equally unforgeable but rotates per process — transport
4270 /// keying must never block protocol initialization. A secret that was
4271 /// installed but could not be persisted is kept in
4272 /// `nostr_unpersisted_secret` so a later attempt retries persisting the
4273 /// same identity instead of rotating it.
4274 pub(crate) fn restore_or_init_nostr_signing_secret(&mut self) {
4275 if self.nostr_secret_persisted {
4276 return;
4277 }
4278 let Some(storage) = self.secure_storage.clone() else {
4279 return;
4280 };
4281 let Some(nostr_arc) = self.transport_manager.get_transport(TransportType::Nostr) else {
4282 // Nostr transport not registered — nothing to key.
4283 return;
4284 };
4285
4286 let (secret, persisted): (Zeroizing<[u8; 32]>, bool) = match storage.load(
4287 storage_keys::NOSTR_SIGNING_SECRET,
4288 storage_keys::NOSTR_SIGNING_SECRET_ID,
4289 ) {
4290 Ok(Some(bytes)) if bytes.len() == 32 => {
4291 let bytes = Zeroizing::new(bytes);
4292 let mut secret = Zeroizing::new([0u8; 32]);
4293 secret.copy_from_slice(&bytes);
4294 // A stored secret supersedes anything a previous attempt
4295 // failed to persist.
4296 self.nostr_unpersisted_secret = None;
4297 debug!("Restored persistent Nostr signing secret from storage");
4298 (secret, true)
4299 }
4300 Ok(other) => {
4301 // Absent, or present but corrupt/wrong-length: persist a
4302 // fresh secret (a wrong-length blob is overwritten so a
4303 // single corrupt write does not pin every future session to
4304 // the ephemeral key). Prefer a secret a previous attempt
4305 // installed but failed to persist, so the retry keeps the
4306 // identity already in use instead of rotating it again.
4307 if other.is_some() {
4308 warn!("Persisted Nostr signing secret had unexpected length; regenerating");
4309 }
4310 let fresh = match self.nostr_unpersisted_secret.take() {
4311 Some(unpersisted) => unpersisted,
4312 None => match NostrKeypair::generate_install_secret() {
4313 Ok(fresh) => fresh,
4314 Err(e) => {
4315 warn!(error = %e, "Failed to generate Nostr signing secret; keeping ephemeral key");
4316 return;
4317 }
4318 },
4319 };
4320 match storage.store(
4321 storage_keys::NOSTR_SIGNING_SECRET,
4322 storage_keys::NOSTR_SIGNING_SECRET_ID,
4323 &*fresh,
4324 ) {
4325 Ok(()) => {
4326 info!("Generated and persisted per-install Nostr signing secret");
4327 (fresh, true)
4328 }
4329 Err(e) => {
4330 // Install the unpersisted secret anyway: the identity
4331 // is stable for this session and the next entry path
4332 // or launch retries persistence — strictly no worse
4333 // than the ephemeral key.
4334 warn!(error = %e, "Failed to persist Nostr signing secret; Nostr identity is session-local");
4335 (fresh, false)
4336 }
4337 }
4338 }
4339 Err(e) => {
4340 warn!(error = %e, "Failed to load Nostr signing secret; keeping ephemeral key");
4341 return;
4342 }
4343 };
4344
4345 let installed = match nostr_arc.as_any().downcast_ref::<NostrTransport>() {
4346 Some(nostr) => match nostr.install_signing_secret(&*secret) {
4347 Ok(()) => true,
4348 Err(e) => {
4349 warn!(error = %e, "Failed to install Nostr signing key; keeping ephemeral key");
4350 false
4351 }
4352 },
4353 None => {
4354 warn!("Transport registered as Nostr is not a NostrTransport; cannot install signing key");
4355 false
4356 }
4357 };
4358
4359 if !persisted {
4360 // Keep the secret so the next entry path retries persisting this
4361 // same identity rather than generating a new one.
4362 self.nostr_unpersisted_secret = Some(secret);
4363 }
4364 self.nostr_secret_persisted = installed && persisted;
4365 }
4366
4367 // ========================================================================
4368 // NOSTR RECEIVE-WATERMARK PERSISTENCE
4369 // ========================================================================
4370
4371 /// Runs `f` against the registered Nostr transport, or returns `None` if
4372 /// Nostr is not enabled for this instance.
4373 fn with_nostr_transport<R>(&self, f: impl FnOnce(&NostrTransport) -> R) -> Option<R> {
4374 let arc = self.transport_manager.get_transport(TransportType::Nostr)?;
4375 let nostr = arc.as_any().downcast_ref::<NostrTransport>()?;
4376 Some(f(nostr))
4377 }
4378
4379 /// Records that a Nostr relay event dated `created_at_secs` (unix seconds)
4380 /// was received and decoded, advancing the receive watermark and persisting
4381 /// it if it has moved far enough.
4382 ///
4383 /// Call this only for frames that deserialized into a `Message` — see
4384 /// [`NostrTransport::advance_receive_watermark`] for why an undecodable
4385 /// frame must not move the mark.
4386 pub fn note_nostr_event_received(&mut self, created_at_secs: i64) {
4387 let advanced = self
4388 .with_nostr_transport(|nostr| nostr.advance_receive_watermark(created_at_secs))
4389 .unwrap_or(false);
4390 if advanced {
4391 self.persist_nostr_watermark();
4392 }
4393 }
4394
4395 /// Debounced Nostr receive-watermark persistence. Writes only once the
4396 /// live mark has advanced past the last persisted one by
4397 /// [`NOSTR_WATERMARK_PERSIST_INTERVAL_SECS`](super::NOSTR_WATERMARK_PERSIST_INTERVAL_SECS),
4398 /// so a busy relay connection does not cost one storage write per inbound
4399 /// event.
4400 pub(crate) fn persist_nostr_watermark(&mut self) {
4401 let Some(current) = self.with_nostr_transport(NostrTransport::receive_watermark_secs)
4402 else {
4403 return;
4404 };
4405 let Some(current) = current else {
4406 return;
4407 };
4408 let due = match self.last_persisted_nostr_watermark {
4409 Some(last) => {
4410 current.saturating_sub(last) >= super::NOSTR_WATERMARK_PERSIST_INTERVAL_SECS
4411 }
4412 // Nothing written yet this session: get a mark on disk promptly, so
4413 // a first launch that receives one message and is killed does not
4414 // start the next launch from a full backfill window.
4415 None => true,
4416 };
4417 if due {
4418 self.write_nostr_watermark_to_storage(current);
4419 }
4420 }
4421
4422 /// Forces the Nostr receive watermark to storage regardless of debounce
4423 /// state. Called on shutdown so the un-flushed tail is not lost.
4424 pub(crate) fn flush_nostr_watermark(&mut self) {
4425 let Some(Some(current)) = self.with_nostr_transport(NostrTransport::receive_watermark_secs)
4426 else {
4427 return;
4428 };
4429 if self.last_persisted_nostr_watermark != Some(current) {
4430 self.write_nostr_watermark_to_storage(current);
4431 }
4432 }
4433
4434 fn write_nostr_watermark_to_storage(&mut self, value: i64) {
4435 let Some(storage) = self.protocol_state_storage.clone() else {
4436 return;
4437 };
4438 let bytes = value.to_le_bytes();
4439 if let Err(e) = self.write_state_record(
4440 storage.as_ref(),
4441 storage_keys::NOSTR_WATERMARK,
4442 storage_keys::NOSTR_WATERMARK_ID,
4443 &bytes,
4444 ) {
4445 warn!(error = %e, "Failed to persist Nostr receive watermark");
4446 return;
4447 }
4448 self.last_persisted_nostr_watermark = Some(value);
4449 }
4450
4451 /// Restores the Nostr receive watermark from storage into the transport.
4452 ///
4453 /// Installs through the transport's own `advance_receive_watermark`, so the
4454 /// restore inherits both of its rules: it can only move the mark *forward*
4455 /// (an instance that already received an event this session keeps the newer
4456 /// value), and a record dated in the future — written by an older build
4457 /// without that check, or edited in the container — is ignored rather than
4458 /// allowed to stall every subsequent subscription.
4459 ///
4460 /// A missing record is the normal first-run case and is not an error: the
4461 /// transport falls back to its bounded first-run backfill window.
4462 pub(crate) fn restore_nostr_watermark(&mut self) {
4463 let Some(storage) = self.protocol_state_storage.clone() else {
4464 return;
4465 };
4466 if self
4467 .transport_manager
4468 .get_transport(TransportType::Nostr)
4469 .is_none()
4470 {
4471 // Nostr not enabled — nothing to install into.
4472 return;
4473 }
4474
4475 // A *missing* record is the ordinary first run; a read *error* is a
4476 // storage problem, and collapsing the two would hide it behind the
4477 // most common path in the function.
4478 let data = match self.read_state_record(
4479 storage.as_ref(),
4480 storage_keys::NOSTR_WATERMARK,
4481 storage_keys::NOSTR_WATERMARK_ID,
4482 ) {
4483 Ok(Some(data)) => data,
4484 Ok(None) => return,
4485 Err(e) => {
4486 warn!(
4487 error = %e,
4488 "Failed to read the Nostr receive watermark; \
4489 this launch falls back to the first-run backfill window"
4490 );
4491 return;
4492 }
4493 };
4494
4495 let Ok(bytes) = <[u8; 8]>::try_from(data.as_slice()) else {
4496 warn!(
4497 len = data.len(),
4498 "Corrupted Nostr receive watermark in storage (expected 8 bytes), starting fresh"
4499 );
4500 return;
4501 };
4502 let restored = i64::from_le_bytes(bytes);
4503 self.with_nostr_transport(|nostr| nostr.advance_receive_watermark(restored));
4504 let live = self
4505 .with_nostr_transport(NostrTransport::receive_watermark_secs)
4506 .flatten();
4507
4508 // The debounce baseline is "what is on disk", but only when disk holds
4509 // a value we would ever have written. A record at or below the live
4510 // mark qualifies — whether it was just installed, or superseded by a
4511 // newer in-memory one. A record *above* the live mark is one the
4512 // transport refused as future-dated: adopting it as the baseline would
4513 // make every later `current - baseline` negative, so nothing would be
4514 // due and the bad record would survive until the next flush. Treat that
4515 // as no baseline, so the next accepted event overwrites it promptly.
4516 // One predicate, used for both the debounce baseline and the log, so
4517 // the two can never disagree about whether the record was adopted.
4518 let adopted = matches!(live, Some(live) if live >= restored);
4519 self.last_persisted_nostr_watermark = adopted.then_some(restored);
4520
4521 if adopted {
4522 debug!(
4523 watermark = restored,
4524 live, "Restored Nostr receive watermark from storage"
4525 );
4526 } else {
4527 // Say which outcome this is. A refused record installs nothing, so
4528 // logging "restored" here would mislead exactly when someone is
4529 // debugging the failure this path exists to contain — a
4530 // subscription stalled by a future-dated mark.
4531 warn!(
4532 watermark = restored,
4533 live,
4534 "Refused a future-dated Nostr receive watermark from storage; \
4535 keeping the live mark and rewriting on the next accepted event"
4536 );
4537 }
4538 }
4539
4540 /// Installs the persisted Nostr state into a transport that was registered
4541 /// *after* [`Self::initialize_mls`] ran.
4542 ///
4543 /// Both restores below are no-ops when no Nostr transport is registered,
4544 /// which is exactly the state `initialize_mls` runs them in for the FFI:
4545 /// the Nostr transport is derived from this device's address, so the
4546 /// bindings layer cannot construct it until `initialize_mls` has produced
4547 /// one, and it installs the transport immediately afterwards. Without this
4548 /// second pass that transport keeps the ephemeral signing key it was
4549 /// constructed with — rotating this install's Nostr identity on every
4550 /// launch, so peers who sealed to the pubkey we advertised last time get
4551 /// frames we can no longer open — and starts with no receive watermark, so
4552 /// every cold start replays the full first-run backfill window.
4553 ///
4554 /// Safe to call whether or not the first pass found a transport: the
4555 /// signing-secret restore is guarded by `nostr_secret_persisted`, which a
4556 /// transport-less pass leaves unset, and the watermark only ever moves
4557 /// forward.
4558 pub fn restore_nostr_transport_state(&mut self) {
4559 self.restore_or_init_nostr_signing_secret();
4560 self.restore_nostr_watermark();
4561 }
4562
4563 /// Installs `secret` as the telemetry fallback secret and rebuilds the
4564 /// pre-install scrubber so the legacy MLS observability path also hashes
4565 /// with the stable secret. Does not touch an already-installed
4566 /// [`crate::telemetry::TelemetryContext`] (rebuilding a live context would
4567 /// rotate opaque ids mid-run); apps that need stable ids should provide
4568 /// storage before installing a telemetry sink.
4569 fn adopt_fallback_secret(&mut self, secret: [u8; 16]) {
4570 self.telemetry_fallback_secret = secret;
4571 self.telemetry_scrubber = crate::telemetry::Scrubber::from_config(
4572 &crate::telemetry::TelemetryConfig::default(),
4573 secret,
4574 );
4575 }
4576
4577 /// Returns a stable, opaque per-install telemetry identifier, or `None`
4578 /// until the persistent scrub secret is available.
4579 ///
4580 /// The id is `SHA-256(secret || domain)` truncated to a 32-character hex
4581 /// string, where `secret` is the per-install scrub secret managed by
4582 /// `Self::restore_or_init_scrub_secret`. The secret cannot be recovered
4583 /// from the id, and the fixed domain string keeps the id un-correlatable
4584 /// with opaque identifiers the scrubber produces for telemetry records:
4585 /// the domain contains `:`, which id validation
4586 /// (`offline_protocol_core::types::validate_id_chars`) rejects in every
4587 /// `UserId`/`AppId`, so no validated identifier reaching the scrubber can
4588 /// ever equal the domain and collide with the install id.
4589 ///
4590 /// Returns `None` while the SDK is still on the random per-instance
4591 /// fallback secret — i.e. before secure storage is provided via
4592 /// [`super::OfflineProtocol::initialize_mls`], or when persistence failed
4593 /// this session. In that state the id would not be stable across launches,
4594 /// so none is exposed.
4595 ///
4596 /// Deliberately derived from the persistent fallback secret, not from an
4597 /// installed [`crate::telemetry::TelemetryConfig::with_scrub_secret`]
4598 /// override: the install id must not rotate when a sink is (re)installed,
4599 /// and must not be computable from an app-chosen secret.
4600 ///
4601 /// The domain string is part of the public contract: changing it would
4602 /// silently rotate every device's install id. Frozen — do not edit.
4603 pub fn telemetry_install_id(&self) -> Option<String> {
4604 const TELEMETRY_INSTALL_ID_DOMAIN: &str = "telemetry:install-id";
4605 self.telemetry_secret_persisted.then(|| {
4606 crate::telemetry::scrubber::opaque_id(
4607 TELEMETRY_INSTALL_ID_DOMAIN,
4608 &self.telemetry_fallback_secret,
4609 )
4610 })
4611 }
4612
4613 // ========================================================================
4614 // LAMPORT CLOCK PERSISTENCE
4615 // ========================================================================
4616
4617 /// Debounced Lamport clock persistence. Only writes to storage when the
4618 /// in-memory value has advanced past `last_persisted_lamport` by at least
4619 /// `LAMPORT_PERSIST_INTERVAL` ticks. This avoids a Keychain/Keystore
4620 /// write on every sent and received message.
4621 pub(crate) fn persist_lamport_clock(&mut self) {
4622 let current = self.lamport_clock.value();
4623 if current.wrapping_sub(self.last_persisted_lamport) < super::LAMPORT_PERSIST_INTERVAL {
4624 return;
4625 }
4626 self.write_lamport_clock_to_storage(current);
4627 }
4628
4629 /// Forces the Lamport clock to storage regardless of debounce state.
4630 /// Called on shutdown to avoid losing any un-flushed ticks.
4631 pub(crate) fn flush_lamport_clock(&mut self) {
4632 self.write_lamport_clock_to_storage(self.lamport_clock.value());
4633 }
4634
4635 fn write_lamport_clock_to_storage(&mut self, value: u64) {
4636 let Some(storage) = self.protocol_state_storage.clone() else {
4637 return;
4638 };
4639 let bytes = value.to_le_bytes();
4640 if let Err(e) = self.write_state_record(
4641 storage.as_ref(),
4642 storage_keys::LAMPORT_CLOCK,
4643 storage_keys::LAMPORT_CLOCK_ID,
4644 &bytes,
4645 ) {
4646 warn!(error = %e, "Failed to persist Lamport clock");
4647 return;
4648 }
4649 self.last_persisted_lamport = value;
4650 }
4651
4652 /// Restores the Lamport clock from storage.
4653 ///
4654 /// Uses `max(current, restored)` so the clock never goes backward even
4655 /// if the in-memory value has advanced before storage was attached.
4656 pub(crate) fn restore_lamport_clock(&mut self) {
4657 let Some(storage) = self.protocol_state_storage.clone() else {
4658 return;
4659 };
4660 if let Ok(Some(data)) = self.read_state_record(
4661 storage.as_ref(),
4662 storage_keys::LAMPORT_CLOCK,
4663 storage_keys::LAMPORT_CLOCK_ID,
4664 ) {
4665 if data.len() == 8 {
4666 let restored = u64::from_le_bytes(data.try_into().expect("verified length is 8"));
4667 let restored_clock = LamportClock::from_value(restored);
4668 if restored_clock > self.lamport_clock {
4669 self.lamport_clock = restored_clock;
4670 }
4671 self.last_persisted_lamport = self.lamport_clock.value();
4672 debug!(clock = %self.lamport_clock, "Restored Lamport clock from storage");
4673 } else {
4674 warn!(
4675 len = data.len(),
4676 "Corrupted Lamport clock in storage (expected 8 bytes), starting fresh"
4677 );
4678 }
4679 }
4680 }
4681}