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