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