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