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