Skip to main content

mail4agent_messenger/store/
mod.rs

1//! The working-set store: [`Store`], the in-memory `CryptoStore`/
2//! `StateStore` implementation every later piece (crypto, room, sync engine)
3//! codes against, plus the sealed-record export/import protocol that ties it
4//! to [`crate::persist::FlushGate`].
5//!
6//! # Shape (plan decision #3, as refined for this piece)
7//!
8//! The plan's own §3.2 sketches `CryptoStore`/`StateStore` as `Send + Sync`
9//! traits called through `Arc<dyn Trait>` with `&self` mutation (interior
10//! mutability hidden inside the impl). That shape is **not** what this crate
11//! builds: decision #3 also settles that the core owns its store **by
12//! value**, single-threaded, so both traits here take `&mut self` for every
13//! mutation, add no `Send`/`Sync` bound, and [`Store`] is a plain
14//! (non-`Arc`, non-`dyn`) struct a `MessengerCore` (a later piece) holds
15//! directly. Every other piece of the plan's method *set* — which
16//! operations exist, what each is keyed by — survives unchanged; only the
17//! calling convention and the value types do not (the pickled Olm/Megolm/
18//! cross-signing types the plan names, e.g. `PickledAccount`, are later
19//! pieces' work — at this layer every value is an opaque blob, because this
20//! store only has to guarantee *persistence* semantics, never interpret
21//! what it persists).
22//!
23//! # Sealing is a codec, not a concrete algorithm
24//!
25//! [`RecordCodec`] is the seam between "how a value is turned into bytes
26//! safe to hand a shell" and everything else in this module. [`Store`] is
27//! generic over it (`Store<C: RecordCodec>`) rather than owning a concrete
28//! encryption implementation, so this piece never needed the real
29//! AES-256-GCM codec the plan's §3.3 describes up front —
30//! [`sealed::SealedRecordCodec`] (M14) is that codec now: AES-256-GCM,
31//! binding the record key as AAD, keyed by a caller-supplied 32-byte
32//! [`crate::core::CoreSecrets::store_seal_key`].
33//! [`InsecurePlainCodecForTests`] stays as the crate's own test-only codec:
34//! an identity function, good for nothing but letting this crate's own
35//! tests build and round-trip a [`Store`] without a seal key. It is
36//! gated behind the `test-support` Cargo feature (off by default, turned on
37//! for this crate's own test builds only — see its own doc comment) so no
38//! shell can compile against it by accident.
39//!
40//! # Key layout
41//!
42//! Every record [`Store`] persists lives under `messenger/{device_id}/...`,
43//! extending the plan's §3.3 table with the record kinds §3.2's trait list
44//! names but §3.3's own table did not enumerate (tracked users, withheld
45//! codes, backup key material, cross-signing key material) — same prefix
46//! convention, same "one flat `Storage` namespace"
47//! shape:
48//!
49//! | Key | Contents |
50//! |---|---|
51//! | `messenger/{device_id}/account` | pickled Olm account |
52//! | `messenger/{device_id}/olm_session/{curve25519}/{session_id}` | one pickled Olm session, keyed by its OWN session id -- a later save for the SAME `(curve25519, session_id)` REPLACES this record, it never appends a sibling (see [`CryptoStore::save_olm_session`]'s own doc for why: an un-replaced older ratchet snapshot is a forward-secrecy leak and a replay vector, not just wasted space) |
53//! | `messenger/{device_id}/olm_session_order/{curve25519}` | JSON array of that peer's session ids, least-recently-used first -- [`Store`]'s own bookkeeping for eviction and "try newest-used first" ordering, never exposed as one of [`CryptoStore`]'s opaque values |
54//! | `messenger/{device_id}/megolm_in/{room_id}/{session_id}` | one pickled inbound Megolm session |
55//! | `messenger/{device_id}/megolm_out/{room_id}` | the room's pickled outbound Megolm session |
56//! | `messenger/{device_id}/devices/{user_id}` | that user's tracked device list |
57//! | `messenger/{device_id}/tracked_user/{user_id}` | a single `0`/`1` byte: the user's outdated flag |
58//! | `messenger/{device_id}/withheld/{room_id}/{session_id}` | a withheld-reason code |
59//! | `messenger/{device_id}/backup_key` | pickled key-backup material |
60//! | `messenger/{device_id}/cross_signing_keys` | pickled cross-signing key material |
61//! | `messenger/{device_id}/sync_token` | the `/sync` `next_batch` token, UTF-8 |
62//! | `messenger/{device_id}/room_state/{room_id}` | that room's serialized state blob |
63//! | `messenger/{device_id}/pending/{request_id}` | one not-yet-acked [`crate::outgoing_queue::PendingRequest`] (the request itself, its lane, and its flush epoch), JSON |
64//! | `messenger/{device_id}/counters` | [`crate::core::Counters`] (M13a): the next [`crate::ids::RequestId`]/[`crate::ids::TxnId`] seed this device has never handed out, JSON |
65//!
66//! Every dynamic path component (`{curve25519}`, `{room_id}`, `{user_id}`,
67//! `{session_id}`, `{request_id}`) is percent-encoded with the exact scheme
68//! [`crate::wire::requests`] already uses for HTTP paths (reused, not
69//! reinvented) — required because a Megolm `session_id` or a legacy device
70//! curve key is base64 and may itself contain `/`, which would otherwise be
71//! indistinguishable from this layout's own path separators.
72//!
73//! `messenger/local_device_id` (plaintext, needed before a seal key can even
74//! be derived — see the plan's §3.3 note) is deliberately **not** handled by
75//! this module: [`Store::load`]/[`Store::new`] both already take `device_id`
76//! as an explicit parameter, so deciding which device's namespace to open is
77//! a shell/M14 concern, not this store's.
78//!
79//! # Forward compatibility
80//!
81//! A key whose kind segment (`account`, `olm_session`, ...) this version of
82//! the store does not recognize at all is **not** an error: [`Store::load`]
83//! keeps its sealed bytes verbatim and immediately re-adopts them into the
84//! very next [`FlushGate::take_batch`] call, unchanged, so a field a newer
85//! writer introduced is never silently dropped by an older reader. A key
86//! whose kind segment *is* recognized but whose structure doesn't match that
87//! kind's shape (a missing id component, a non-numeric session index, ...)
88//! is the opposite: a hard [`StoreError::UnknownRecordLayout`], because at
89//! that point this store knows enough to know something is wrong, not
90//! enough to know what to do about it.
91
92pub mod sealed;
93
94use crate::core::Counters;
95use crate::ids::{DeviceId, RequestId, RoomId, UserId};
96use crate::outgoing_queue::PendingRequest;
97use crate::persist::{FlushBatch, FlushEpoch, FlushGate, RecordKey, RequiredSeq, SealedRecord};
98use crate::wire::{percent_decode_segment, percent_encode_segment};
99use std::collections::BTreeMap;
100
101/// Every error [`Store::load`] or a mutation can return. All three variants
102/// name the offending [`RecordKey`] — nothing here fails silently or points
103/// only at "a record", ever.
104#[derive(Debug, thiserror::Error)]
105pub enum StoreError {
106    /// The record's own bytes (after a successful [`RecordCodec::open`])
107    /// don't match what this key's kind expects — non-UTF-8 bytes where a
108    /// sync token string was expected, JSON that doesn't parse as an
109    /// [`OutgoingRequest`], and so on.
110    #[error("record {key}: {reason}")]
111    Decode {
112        /// The record whose plaintext failed to decode.
113        key: RecordKey,
114        /// What about it was wrong.
115        reason: String,
116    },
117
118    /// [`RecordCodec::open`] rejected a record's sealed bytes outright —
119    /// wrong key, corrupted bytes, or (for the real AES-GCM codec a later
120    /// piece adds) an authentication failure.
121    #[error("record {key}: codec rejected the sealed bytes")]
122    CodecOpen {
123        /// The record the codec refused to open.
124        key: RecordKey,
125    },
126
127    /// The key's kind segment (e.g. `"olm_session"`) is recognized, but the
128    /// rest of the key does not match that kind's expected shape. Distinct
129    /// from a genuinely unrecognized kind, which [`Store::load`] keeps
130    /// verbatim rather than treating as an error — see the module doc's
131    /// "Forward compatibility" section.
132    #[error("record {key}: recognized kind but malformed key layout")]
133    UnknownRecordLayout {
134        /// The malformed record's key.
135        key: RecordKey,
136    },
137}
138
139impl StoreError {
140    fn decode(key: &RecordKey, reason: impl Into<String>) -> Self {
141        StoreError::Decode { key: key.clone(), reason: reason.into() }
142    }
143
144    fn unknown_layout(key: &RecordKey) -> Self {
145        StoreError::UnknownRecordLayout { key: key.clone() }
146    }
147}
148
149/// Turns a record's plaintext into bytes safe to hand a shell, and back.
150/// [`Store`] calls [`seal`](RecordCodec::seal) on every mutation before
151/// handing the result to [`FlushGate::mark_dirty`], and calls
152/// [`open`](RecordCodec::open) once per record during [`Store::load`]. The
153/// real codec (AES-256-GCM, binding `key` as AAD) is a later piece (plan
154/// §3.3, M14); this piece only ships [`InsecurePlainCodecForTests`].
155pub trait RecordCodec {
156    /// Seals `plaintext` for storage at `key`. Infallible by construction:
157    /// sealing (unlike opening) has no "wrong key" or "corrupted input" case
158    /// to reject.
159    fn seal(&self, key: &RecordKey, plaintext: &[u8]) -> Vec<u8>;
160
161    /// Recovers the plaintext `seal` produced for `key`, or a
162    /// [`StoreError::CodecOpen`] naming `key` if `sealed` was not produced
163    /// by this codec for this key (wrong key, corrupted bytes, or a failed
164    /// AEAD authentication check for a real codec).
165    fn open(&self, key: &RecordKey, sealed: &[u8]) -> Result<Vec<u8>, StoreError>;
166}
167
168/// An identity [`RecordCodec`] that provides **no confidentiality
169/// whatsoever** — `seal` and `open` are both the identity function. Exists
170/// only so this crate's own tests (and, later, its `tests/e2e_*.rs`
171/// integration tests) can build and round-trip a [`Store`] without a real
172/// vault key. Never wire this into anything a shell actually persists.
173///
174/// Gated behind the `test-support` Cargo feature (off by default) so no
175/// shell, or any other crate depending on this one normally, can link
176/// against a codec that provides zero confidentiality by accident. This
177/// crate's own test builds turn the feature on via the self-referential
178/// `[dev-dependencies]` entry in `Cargo.toml`.
179#[cfg(feature = "test-support")]
180#[derive(Debug, Default, Clone, Copy)]
181pub struct InsecurePlainCodecForTests;
182
183#[cfg(feature = "test-support")]
184impl RecordCodec for InsecurePlainCodecForTests {
185    fn seal(&self, _key: &RecordKey, plaintext: &[u8]) -> Vec<u8> {
186        plaintext.to_vec()
187    }
188
189    fn open(&self, _key: &RecordKey, sealed: &[u8]) -> Result<Vec<u8>, StoreError> {
190        Ok(sealed.to_vec())
191    }
192}
193
194/// The Olm/Megolm/cross-signing/backup half of the working set — every
195/// method the plan's §3.2 `CryptoStore` names, kept as this trait's method
196/// set (see the module doc for what changed and why). Every value is an
197/// opaque blob: the typed pickled objects these blobs will eventually hold
198/// are later pieces' work (crypto/account.rs and friends), not this one's.
199pub trait CryptoStore {
200    /// The pickled Olm account, if one has been created yet.
201    fn account(&self) -> Result<Option<&[u8]>, StoreError>;
202    /// Persists the pickled Olm account, replacing any earlier one.
203    fn save_account(&mut self, account: Vec<u8>) -> Result<(), StoreError>;
204
205    /// Every pickled Olm session this store currently has for
206    /// `their_curve25519`, ordered MOST-recently-used first (see
207    /// [`Store::save_olm_session`]'s own doc for what "used" means here).
208    /// At most [`MAX_OLM_SESSIONS_PER_DEVICE`] entries -- older, less
209    /// recently used sessions are evicted, never accumulated. Empty (never
210    /// an error) if none exist yet.
211    fn olm_sessions_for_device(&self, their_curve25519: &str) -> Result<Vec<Vec<u8>>, StoreError>;
212    /// Persists the pickled Olm session identified by `session_id`,
213    /// REPLACING any earlier record for the same `(their_curve25519,
214    /// session_id)` pair and marking it the most-recently-used session for
215    /// that peer. This is a hard requirement, not an optimization: an Olm
216    /// session's own pickle is its FULL ratchet state, including every
217    /// chain/message key it has ever derived and not yet forgotten. Keeping
218    /// an older snapshot of the SAME session_id around after a newer one
219    /// has been saved would mean an already-consumed message key survives
220    /// on disk somewhere the newer snapshot itself no longer has it --
221    /// exactly the forward-secrecy property Olm's ratchet exists to
222    /// provide, and exactly the kind of stale state a decrypt path must
223    /// never be able to resurrect to replay an already-decrypted message.
224    /// A caller with a genuinely different session for the same peer
225    /// (e.g. two independently-established sessions from a real Olm race)
226    /// passes a different `session_id` and gets a second, independent
227    /// slot -- up to [`MAX_OLM_SESSIONS_PER_DEVICE`] per peer; saving
228    /// beyond that evicts the least-recently-used one (its record is
229    /// deleted, riding in the same flush batch as this save).
230    fn save_olm_session(&mut self, their_curve25519: &str, session_id: &str, session: Vec<u8>) -> Result<(), StoreError>;
231
232    /// The pickled inbound Megolm session for `(room_id, session_id)`, if
233    /// this device has ever received one.
234    fn inbound_group_session(
235        &self,
236        room_id: &RoomId,
237        session_id: &str,
238    ) -> Result<Option<&[u8]>, StoreError>;
239    /// Persists a pickled inbound Megolm session, replacing any earlier one
240    /// for the same `(room_id, session_id)`.
241    fn save_inbound_group_session(
242        &mut self,
243        room_id: &RoomId,
244        session_id: &str,
245        session: Vec<u8>,
246    ) -> Result<(), StoreError>;
247
248    /// The room's current pickled outbound Megolm session, if one exists.
249    fn outbound_group_session(&self, room_id: &RoomId) -> Result<Option<&[u8]>, StoreError>;
250    /// Persists the room's pickled outbound Megolm session, replacing any
251    /// earlier one.
252    fn save_outbound_group_session(&mut self, room_id: &RoomId, session: Vec<u8>) -> Result<(), StoreError>;
253
254    /// The serialized device list this store has cached for `user_id`, if
255    /// any.
256    fn devices_for_user(&self, user_id: &UserId) -> Result<Option<&[u8]>, StoreError>;
257    /// Replaces the cached device list for `user_id`.
258    fn save_devices(&mut self, user_id: &UserId, devices: Vec<u8>) -> Result<(), StoreError>;
259
260    /// Every user this device tracks device lists for, with its outdated
261    /// flag.
262    fn tracked_users(&self) -> Result<Vec<(UserId, bool)>, StoreError>;
263    /// Starts (or updates) tracking `user_id`, recording whether its device
264    /// list is known to be stale.
265    fn mark_user_tracked(&mut self, user_id: &UserId, outdated: bool) -> Result<(), StoreError>;
266
267    /// The withheld-reason code recorded for `(room_id, session_id)`, if
268    /// any.
269    fn withheld_reason(&self, room_id: &RoomId, session_id: &str) -> Result<Option<&[u8]>, StoreError>;
270    /// Records a withheld-reason code for `(room_id, session_id)`.
271    fn save_withheld(&mut self, room_id: &RoomId, session_id: &str, code: Vec<u8>) -> Result<(), StoreError>;
272
273    /// The pickled key-backup material, if one has been set up.
274    fn backup_key(&self) -> Result<Option<&[u8]>, StoreError>;
275    /// Persists pickled key-backup material, replacing any earlier one.
276    fn save_backup_key(&mut self, key: Vec<u8>) -> Result<(), StoreError>;
277
278    /// The pickled cross-signing key material, if one has been set up.
279    fn cross_signing_keys(&self) -> Result<Option<&[u8]>, StoreError>;
280    /// Persists pickled cross-signing key material, replacing any earlier
281    /// one.
282    fn save_cross_signing_keys(&mut self, keys: Vec<u8>) -> Result<(), StoreError>;
283}
284
285/// The sync/room half of the working set — every method the plan's §3.2
286/// `StateStore` names, kept as this trait's method set. `pending_requests`
287/// carries [`PendingRequest`] (plan §5's `outgoing_queue::PendingRequest`,
288/// M12): the wire request itself, plus the [`crate::outgoing_queue::Lane`]
289/// it belongs to and the [`RequiredSeq`] it must wait on, so a crash
290/// restart can rebuild `outgoing_queue::OutgoingQueue` without losing
291/// either.
292pub trait StateStore {
293    /// The `/sync` `next_batch` token from the most recent successful sync,
294    /// if any.
295    fn sync_token(&self) -> Result<Option<&str>, StoreError>;
296    /// Persists the `/sync` `next_batch` token, replacing any earlier one.
297    fn save_sync_token(&mut self, token: String) -> Result<(), StoreError>;
298
299    /// The serialized state blob for `room_id`, if this store has one.
300    fn room_state(&self, room_id: &RoomId) -> Result<Option<&[u8]>, StoreError>;
301    /// Replaces the serialized state blob for `room_id`.
302    fn save_room_state(&mut self, room_id: &RoomId, state: Vec<u8>) -> Result<(), StoreError>;
303    /// Every room this store currently has state for.
304    fn room_ids(&self) -> Result<Vec<RoomId>, StoreError>;
305
306    /// Every outgoing request still waiting to be sent or acked.
307    fn pending_requests(&self) -> Result<Vec<PendingRequest>, StoreError>;
308    /// Records an outgoing request as pending (survives a crash/restart
309    /// until [`StateStore::delete_pending_request`] removes it).
310    fn save_pending_request(&mut self, req: PendingRequest) -> Result<(), StoreError>;
311    /// Removes a pending request, e.g. once its response has arrived. A
312    /// no-op if `id` is not currently pending.
313    fn delete_pending_request(&mut self, id: &RequestId) -> Result<(), StoreError>;
314
315    /// The most recently persisted [`Counters`], if this device has ever
316    /// minted a [`crate::ids::RequestId`]/[`crate::ids::TxnId`] before.
317    /// `None` only for a brand-new device — [`crate::core::MessengerCore::open`]
318    /// treats that the same as `Counters::default()`.
319    fn counters(&self) -> Result<Option<Counters>, StoreError>;
320    /// Persists `counters`, replacing any earlier value. Called every time
321    /// either sequence advances — see [`crate::core`]'s module doc for why
322    /// this write must land in the same flush batch as (or an earlier one
323    /// than) any request carrying the id it just minted.
324    fn save_counters(&mut self, counters: Counters) -> Result<(), StoreError>;
325}
326
327/// How many Olm sessions [`Store`] keeps per peer curve25519 key before
328/// evicting the least-recently-used one -- see [`CryptoStore::
329/// save_olm_session`]'s own doc for why eviction (not accumulation) is the
330/// only safe policy for a superseded session id, and why a genuinely
331/// distinct session id still gets its own slot up to this cap.
332const MAX_OLM_SESSIONS_PER_DEVICE: usize = 10;
333
334const KIND_ACCOUNT: &str = "account";
335const KIND_OLM_SESSION: &str = "olm_session";
336const KIND_OLM_SESSION_ORDER: &str = "olm_session_order";
337const KIND_MEGOLM_IN: &str = "megolm_in";
338const KIND_MEGOLM_OUT: &str = "megolm_out";
339const KIND_DEVICES: &str = "devices";
340const KIND_TRACKED_USER: &str = "tracked_user";
341const KIND_WITHHELD: &str = "withheld";
342const KIND_BACKUP_KEY: &str = "backup_key";
343const KIND_CROSS_SIGNING_KEYS: &str = "cross_signing_keys";
344const KIND_SYNC_TOKEN: &str = "sync_token";
345const KIND_ROOM_STATE: &str = "room_state";
346const KIND_PENDING: &str = "pending";
347const KIND_COUNTERS: &str = "counters";
348
349fn account_record_key(device_id: &DeviceId) -> RecordKey {
350    RecordKey::new(format!("messenger/{}/{KIND_ACCOUNT}", device_id.as_str()))
351}
352
353fn olm_session_record_key(device_id: &DeviceId, their_curve25519: &str, session_id: &str) -> RecordKey {
354    RecordKey::new(format!(
355        "messenger/{}/{KIND_OLM_SESSION}/{}/{}",
356        device_id.as_str(),
357        percent_encode_segment(their_curve25519),
358        percent_encode_segment(session_id),
359    ))
360}
361
362fn olm_session_order_record_key(device_id: &DeviceId, their_curve25519: &str) -> RecordKey {
363    RecordKey::new(format!(
364        "messenger/{}/{KIND_OLM_SESSION_ORDER}/{}",
365        device_id.as_str(),
366        percent_encode_segment(their_curve25519),
367    ))
368}
369
370fn inbound_group_session_record_key(device_id: &DeviceId, room_id: &RoomId, session_id: &str) -> RecordKey {
371    RecordKey::new(format!(
372        "messenger/{}/{KIND_MEGOLM_IN}/{}/{}",
373        device_id.as_str(),
374        percent_encode_segment(room_id.as_str()),
375        percent_encode_segment(session_id),
376    ))
377}
378
379fn outbound_group_session_record_key(device_id: &DeviceId, room_id: &RoomId) -> RecordKey {
380    RecordKey::new(format!(
381        "messenger/{}/{KIND_MEGOLM_OUT}/{}",
382        device_id.as_str(),
383        percent_encode_segment(room_id.as_str()),
384    ))
385}
386
387fn devices_record_key(device_id: &DeviceId, user_id: &UserId) -> RecordKey {
388    RecordKey::new(format!(
389        "messenger/{}/{KIND_DEVICES}/{}",
390        device_id.as_str(),
391        percent_encode_segment(user_id.as_str()),
392    ))
393}
394
395fn tracked_user_record_key(device_id: &DeviceId, user_id: &UserId) -> RecordKey {
396    RecordKey::new(format!(
397        "messenger/{}/{KIND_TRACKED_USER}/{}",
398        device_id.as_str(),
399        percent_encode_segment(user_id.as_str()),
400    ))
401}
402
403fn withheld_record_key(device_id: &DeviceId, room_id: &RoomId, session_id: &str) -> RecordKey {
404    RecordKey::new(format!(
405        "messenger/{}/{KIND_WITHHELD}/{}/{}",
406        device_id.as_str(),
407        percent_encode_segment(room_id.as_str()),
408        percent_encode_segment(session_id),
409    ))
410}
411
412fn backup_key_record_key(device_id: &DeviceId) -> RecordKey {
413    RecordKey::new(format!("messenger/{}/{KIND_BACKUP_KEY}", device_id.as_str()))
414}
415
416fn cross_signing_keys_record_key(device_id: &DeviceId) -> RecordKey {
417    RecordKey::new(format!("messenger/{}/{KIND_CROSS_SIGNING_KEYS}", device_id.as_str()))
418}
419
420fn sync_token_record_key(device_id: &DeviceId) -> RecordKey {
421    RecordKey::new(format!("messenger/{}/{KIND_SYNC_TOKEN}", device_id.as_str()))
422}
423
424fn room_state_record_key(device_id: &DeviceId, room_id: &RoomId) -> RecordKey {
425    RecordKey::new(format!(
426        "messenger/{}/{KIND_ROOM_STATE}/{}",
427        device_id.as_str(),
428        percent_encode_segment(room_id.as_str()),
429    ))
430}
431
432fn pending_request_record_key(device_id: &DeviceId, request_id: &RequestId) -> RecordKey {
433    RecordKey::new(format!(
434        "messenger/{}/{KIND_PENDING}/{}",
435        device_id.as_str(),
436        percent_encode_segment(request_id.as_str()),
437    ))
438}
439
440fn counters_record_key(device_id: &DeviceId) -> RecordKey {
441    RecordKey::new(format!("messenger/{}/{KIND_COUNTERS}", device_id.as_str()))
442}
443
444/// A sealed record's key, parsed into the record kind it names plus that
445/// kind's decoded dynamic components. Produced by [`parse_record_key`].
446enum RecognizedRecord {
447    Account,
448    OlmSession { their_curve25519: String, session_id: String },
449    OlmSessionOrder { their_curve25519: String },
450    InboundGroupSession { room_id: RoomId, session_id: String },
451    OutboundGroupSession { room_id: RoomId },
452    Devices { user_id: UserId },
453    TrackedUser { user_id: UserId },
454    Withheld { room_id: RoomId, session_id: String },
455    BackupKey,
456    CrossSigningKeys,
457    SyncToken,
458    RoomState { room_id: RoomId },
459    PendingRequest { request_id: RequestId },
460    Counters,
461}
462
463fn decode_room_id(encoded: &str, key: &RecordKey) -> Result<RoomId, StoreError> {
464    let decoded = percent_decode_segment(encoded).ok_or_else(|| StoreError::unknown_layout(key))?;
465    RoomId::parse(decoded).map_err(|_| StoreError::unknown_layout(key))
466}
467
468fn decode_user_id(encoded: &str, key: &RecordKey) -> Result<UserId, StoreError> {
469    let decoded = percent_decode_segment(encoded).ok_or_else(|| StoreError::unknown_layout(key))?;
470    UserId::parse(decoded).map_err(|_| StoreError::unknown_layout(key))
471}
472
473fn decode_plain_segment(encoded: &str, key: &RecordKey) -> Result<String, StoreError> {
474    percent_decode_segment(encoded).ok_or_else(|| StoreError::unknown_layout(key))
475}
476
477/// Parses `key` into a [`RecognizedRecord`], scoped to `device_id`'s own
478/// namespace. `Ok(None)` means `key`'s kind segment is not one this version
479/// of the store recognizes at all — see the module doc's "Forward
480/// compatibility" section for why that is not an error. `Err` means the
481/// kind segment *is* recognized but the rest of `key` does not match that
482/// kind's expected shape.
483fn parse_record_key(device_id: &DeviceId, key: &RecordKey) -> Result<Option<RecognizedRecord>, StoreError> {
484    let prefix = format!("messenger/{}/", device_id.as_str());
485    let Some(rest) = key.as_str().strip_prefix(prefix.as_str()) else {
486        return Ok(None);
487    };
488    let mut parts = rest.splitn(2, '/');
489    let kind = parts.next().unwrap_or_default();
490    let remainder = parts.next();
491    let malformed = || StoreError::unknown_layout(key);
492
493    match kind {
494        KIND_ACCOUNT if remainder.is_none() => Ok(Some(RecognizedRecord::Account)),
495        KIND_SYNC_TOKEN if remainder.is_none() => Ok(Some(RecognizedRecord::SyncToken)),
496        KIND_BACKUP_KEY if remainder.is_none() => Ok(Some(RecognizedRecord::BackupKey)),
497        KIND_CROSS_SIGNING_KEYS if remainder.is_none() => Ok(Some(RecognizedRecord::CrossSigningKeys)),
498        KIND_COUNTERS if remainder.is_none() => Ok(Some(RecognizedRecord::Counters)),
499        KIND_OLM_SESSION => {
500            let remainder = remainder.ok_or_else(malformed)?;
501            let (curve_enc, session_enc) = remainder.split_once('/').ok_or_else(malformed)?;
502            let their_curve25519 = decode_plain_segment(curve_enc, key)?;
503            let session_id = decode_plain_segment(session_enc, key)?;
504            Ok(Some(RecognizedRecord::OlmSession { their_curve25519, session_id }))
505        }
506        KIND_OLM_SESSION_ORDER => {
507            let curve_enc = remainder.ok_or_else(malformed)?;
508            Ok(Some(RecognizedRecord::OlmSessionOrder { their_curve25519: decode_plain_segment(curve_enc, key)? }))
509        }
510        KIND_MEGOLM_IN => {
511            let remainder = remainder.ok_or_else(malformed)?;
512            let (room_enc, session_enc) = remainder.split_once('/').ok_or_else(malformed)?;
513            let room_id = decode_room_id(room_enc, key)?;
514            let session_id = decode_plain_segment(session_enc, key)?;
515            Ok(Some(RecognizedRecord::InboundGroupSession { room_id, session_id }))
516        }
517        KIND_MEGOLM_OUT => {
518            let room_enc = remainder.ok_or_else(malformed)?;
519            Ok(Some(RecognizedRecord::OutboundGroupSession { room_id: decode_room_id(room_enc, key)? }))
520        }
521        KIND_DEVICES => {
522            let user_enc = remainder.ok_or_else(malformed)?;
523            Ok(Some(RecognizedRecord::Devices { user_id: decode_user_id(user_enc, key)? }))
524        }
525        KIND_TRACKED_USER => {
526            let user_enc = remainder.ok_or_else(malformed)?;
527            Ok(Some(RecognizedRecord::TrackedUser { user_id: decode_user_id(user_enc, key)? }))
528        }
529        KIND_WITHHELD => {
530            let remainder = remainder.ok_or_else(malformed)?;
531            let (room_enc, session_enc) = remainder.split_once('/').ok_or_else(malformed)?;
532            let room_id = decode_room_id(room_enc, key)?;
533            let session_id = decode_plain_segment(session_enc, key)?;
534            Ok(Some(RecognizedRecord::Withheld { room_id, session_id }))
535        }
536        KIND_ROOM_STATE => {
537            let room_enc = remainder.ok_or_else(malformed)?;
538            Ok(Some(RecognizedRecord::RoomState { room_id: decode_room_id(room_enc, key)? }))
539        }
540        KIND_PENDING => {
541            let request_enc = remainder.ok_or_else(malformed)?;
542            let request_id = RequestId::from(decode_plain_segment(request_enc, key)?);
543            Ok(Some(RecognizedRecord::PendingRequest { request_id }))
544        }
545        _ => Ok(None),
546    }
547}
548
549fn decode_bool(bytes: &[u8], key: &RecordKey) -> Result<bool, StoreError> {
550    match bytes {
551        [0] => Ok(false),
552        [1] => Ok(true),
553        other => Err(StoreError::decode(key, format!("expected a single 0/1 byte, got {} bytes", other.len()))),
554    }
555}
556
557/// The in-memory working set: every mutable piece of state
558/// `mail4agent-messenger`'s core needs across a process lifetime, plus the
559/// [`FlushGate`] that turns mutations into sealed records a shell persists.
560/// Generic over the [`RecordCodec`] used to seal/open every record — see
561/// the module doc's "Sealing is a codec, not a concrete algorithm" section.
562#[derive(Debug)]
563pub struct Store<C: RecordCodec> {
564    device_id: DeviceId,
565    codec: C,
566    gate: FlushGate,
567    account: Option<Vec<u8>>,
568    /// Peer curve25519 -> that peer's sessions, keyed by their own session
569    /// id. `olm_session_order` (below) is the recency ordering over this
570    /// map's keys; the two are always updated together (see
571    /// [`Store::save_olm_session`]).
572    olm_sessions: BTreeMap<String, BTreeMap<String, Vec<u8>>>,
573    /// Peer curve25519 -> that peer's session ids, least-recently-used
574    /// first. Purely this store's own eviction/ordering bookkeeping --
575    /// never one of [`CryptoStore`]'s opaque values.
576    olm_session_order: BTreeMap<String, Vec<String>>,
577    inbound_group_sessions: BTreeMap<RoomId, BTreeMap<String, Vec<u8>>>,
578    outbound_group_sessions: BTreeMap<RoomId, Vec<u8>>,
579    devices: BTreeMap<UserId, Vec<u8>>,
580    tracked_users: BTreeMap<UserId, bool>,
581    withheld: BTreeMap<RoomId, BTreeMap<String, Vec<u8>>>,
582    backup_key: Option<Vec<u8>>,
583    cross_signing_keys: Option<Vec<u8>>,
584    sync_token: Option<String>,
585    room_state: BTreeMap<RoomId, Vec<u8>>,
586    pending_requests: BTreeMap<RequestId, PendingRequest>,
587    counters: Option<Counters>,
588    /// Sealed bytes for keys no kind in this version recognizes, kept
589    /// verbatim across a load — see the module doc's "Forward
590    /// compatibility" section.
591    unknown: BTreeMap<RecordKey, Vec<u8>>,
592}
593
594impl<C: RecordCodec> Store<C> {
595    /// Builds an empty working set for `device_id`, backed by `codec` for
596    /// every future flush export. Nothing is dirty yet — this is the
597    /// brand-new-device case (plan §4.1's first unlock); a shell that
598    /// already has records from an earlier session calls [`Store::load`]
599    /// instead.
600    pub fn new(device_id: DeviceId, codec: C) -> Self {
601        Self {
602            device_id,
603            codec,
604            gate: FlushGate::new(),
605            account: None,
606            olm_sessions: BTreeMap::new(),
607            olm_session_order: BTreeMap::new(),
608            inbound_group_sessions: BTreeMap::new(),
609            outbound_group_sessions: BTreeMap::new(),
610            devices: BTreeMap::new(),
611            tracked_users: BTreeMap::new(),
612            withheld: BTreeMap::new(),
613            backup_key: None,
614            cross_signing_keys: None,
615            sync_token: None,
616            room_state: BTreeMap::new(),
617            pending_requests: BTreeMap::new(),
618            counters: None,
619            unknown: BTreeMap::new(),
620        }
621    }
622
623    /// Rebuilds a working set from sealed records a shell read back from
624    /// durable storage (plan decision #3's `MessengerCore::open`). See the
625    /// module doc's "Forward compatibility" section for what happens to a
626    /// key this version of the store does not recognize, and this type's
627    /// own doc for what a recognized-but-malformed or a
628    /// recognized-but-uninterpretable key does.
629    pub fn load(
630        records: impl IntoIterator<Item = SealedRecord>,
631        codec: C,
632        device_id: DeviceId,
633    ) -> Result<Self, StoreError> {
634        let mut store = Self::new(device_id, codec);
635        for record in records {
636            match parse_record_key(&store.device_id, &record.key)? {
637                Some(recognized) => {
638                    let plaintext = store.codec.open(&record.key, &record.bytes)?;
639                    store.apply_recognized(recognized, plaintext, &record.key)?;
640                }
641                None => {
642                    store.gate.mark_dirty(record.key.clone(), record.bytes.clone());
643                    store.unknown.insert(record.key, record.bytes);
644                }
645            }
646        }
647        Ok(store)
648    }
649
650    fn apply_recognized(
651        &mut self,
652        recognized: RecognizedRecord,
653        plaintext: Vec<u8>,
654        key: &RecordKey,
655    ) -> Result<(), StoreError> {
656        match recognized {
657            RecognizedRecord::Account => self.account = Some(plaintext),
658            RecognizedRecord::OlmSession { their_curve25519, session_id } => {
659                self.olm_sessions.entry(their_curve25519).or_default().insert(session_id, plaintext);
660            }
661            RecognizedRecord::OlmSessionOrder { their_curve25519 } => {
662                let order: Vec<String> = serde_json::from_slice(&plaintext)
663                    .map_err(|source| StoreError::decode(key, format!("session order is not valid JSON: {source}")))?;
664                self.olm_session_order.insert(their_curve25519, order);
665            }
666            RecognizedRecord::InboundGroupSession { room_id, session_id } => {
667                self.inbound_group_sessions.entry(room_id).or_default().insert(session_id, plaintext);
668            }
669            RecognizedRecord::OutboundGroupSession { room_id } => {
670                self.outbound_group_sessions.insert(room_id, plaintext);
671            }
672            RecognizedRecord::Devices { user_id } => {
673                self.devices.insert(user_id, plaintext);
674            }
675            RecognizedRecord::TrackedUser { user_id } => {
676                let outdated = decode_bool(&plaintext, key)?;
677                self.tracked_users.insert(user_id, outdated);
678            }
679            RecognizedRecord::Withheld { room_id, session_id } => {
680                self.withheld.entry(room_id).or_default().insert(session_id, plaintext);
681            }
682            RecognizedRecord::BackupKey => self.backup_key = Some(plaintext),
683            RecognizedRecord::CrossSigningKeys => self.cross_signing_keys = Some(plaintext),
684            RecognizedRecord::SyncToken => {
685                let token = String::from_utf8(plaintext)
686                    .map_err(|_| StoreError::decode(key, "sync token is not valid UTF-8"))?;
687                self.sync_token = Some(token);
688            }
689            RecognizedRecord::RoomState { room_id } => {
690                self.room_state.insert(room_id, plaintext);
691            }
692            RecognizedRecord::PendingRequest { request_id } => {
693                let req: PendingRequest = serde_json::from_slice(&plaintext).map_err(|source| {
694                    StoreError::decode(key, format!("pending request is not valid JSON: {source}"))
695                })?;
696                self.pending_requests.insert(request_id, req);
697            }
698            RecognizedRecord::Counters => {
699                let counters: Counters = serde_json::from_slice(&plaintext)
700                    .map_err(|source| StoreError::decode(key, format!("counters record is not valid JSON: {source}")))?;
701                self.counters = Some(counters);
702            }
703        }
704        Ok(())
705    }
706
707    fn mark_dirty(&mut self, key: RecordKey, plaintext: Vec<u8>) {
708        let sealed = self.codec.seal(&key, &plaintext);
709        self.gate.mark_dirty(key, sealed);
710    }
711
712    fn mark_deleted(&mut self, key: RecordKey) {
713        self.gate.mark_deleted(key);
714    }
715
716    /// Drains everything dirtied since the previous call into one
717    /// [`FlushBatch`] for a shell to write durably — see
718    /// [`FlushGate::take_batch`].
719    pub fn take_flush_batch(&mut self) -> Option<FlushBatch> {
720        self.gate.take_batch()
721    }
722
723    /// Acknowledges that batch `id` has been durably written — see
724    /// [`FlushGate::ack`].
725    pub fn ack_flush(&mut self, id: u64) {
726        self.gate.ack(id);
727    }
728}
729
730impl<C: RecordCodec> FlushEpoch for Store<C> {
731    fn seal_for_request(&mut self) -> (RequiredSeq, Option<FlushBatch>) {
732        self.gate.seal_for_request()
733    }
734
735    fn is_released(&self, required: RequiredSeq) -> bool {
736        self.gate.is_released(required)
737    }
738}
739
740impl<C: RecordCodec> CryptoStore for Store<C> {
741    fn account(&self) -> Result<Option<&[u8]>, StoreError> {
742        Ok(self.account.as_deref())
743    }
744
745    fn save_account(&mut self, account: Vec<u8>) -> Result<(), StoreError> {
746        let key = account_record_key(&self.device_id);
747        self.mark_dirty(key, account.clone());
748        self.account = Some(account);
749        Ok(())
750    }
751
752    fn olm_sessions_for_device(&self, their_curve25519: &str) -> Result<Vec<Vec<u8>>, StoreError> {
753        let Some(sessions) = self.olm_sessions.get(their_curve25519) else { return Ok(Vec::new()) };
754        let Some(order) = self.olm_session_order.get(their_curve25519) else {
755            return Ok(sessions.values().cloned().collect());
756        };
757        // `order` is least-recently-used first; the trait contract is
758        // most-recently-used first (see this method's own doc), hence the
759        // reverse.
760        Ok(order.iter().rev().filter_map(|session_id| sessions.get(session_id).cloned()).collect())
761    }
762
763    fn save_olm_session(&mut self, their_curve25519: &str, session_id: &str, session: Vec<u8>) -> Result<(), StoreError> {
764        // 1. Replace this session id's own record -- never a new sibling
765        // key (see the trait method's own doc for why accumulation here
766        // would be a forward-secrecy leak and a replay vector).
767        let key = olm_session_record_key(&self.device_id, their_curve25519, session_id);
768        self.mark_dirty(key, session.clone());
769        self.olm_sessions.entry(their_curve25519.to_string()).or_default().insert(session_id.to_string(), session);
770
771        // 2. Move this session id to the most-recently-used end of the
772        // order, persisting the updated order record; note (but don't yet
773        // apply) an eviction if the cap is now exceeded.
774        let mut order = self.olm_session_order.remove(their_curve25519).unwrap_or_default();
775        order.retain(|id| id != session_id);
776        order.push(session_id.to_string());
777        let evicted = (order.len() > MAX_OLM_SESSIONS_PER_DEVICE).then(|| order.remove(0));
778
779        let order_key = olm_session_order_record_key(&self.device_id, their_curve25519);
780        let order_bytes = serde_json::to_vec(&order).map_err(|source| {
781            StoreError::decode(&order_key, format!("failed to serialize session order: {source}"))
782        })?;
783        self.mark_dirty(order_key, order_bytes);
784        self.olm_session_order.insert(their_curve25519.to_string(), order);
785
786        // 3. Apply the eviction, if any -- deletes the least-recently-used
787        // session's own record, riding in the same flush batch as the
788        // writes above.
789        if let Some(evicted_id) = evicted {
790            if let Some(sessions) = self.olm_sessions.get_mut(their_curve25519) {
791                sessions.remove(&evicted_id);
792            }
793            self.mark_deleted(olm_session_record_key(&self.device_id, their_curve25519, &evicted_id));
794        }
795        Ok(())
796    }
797
798    fn inbound_group_session(
799        &self,
800        room_id: &RoomId,
801        session_id: &str,
802    ) -> Result<Option<&[u8]>, StoreError> {
803        Ok(self
804            .inbound_group_sessions
805            .get(room_id)
806            .and_then(|sessions| sessions.get(session_id))
807            .map(Vec::as_slice))
808    }
809
810    fn save_inbound_group_session(
811        &mut self,
812        room_id: &RoomId,
813        session_id: &str,
814        session: Vec<u8>,
815    ) -> Result<(), StoreError> {
816        let key = inbound_group_session_record_key(&self.device_id, room_id, session_id);
817        self.mark_dirty(key, session.clone());
818        self.inbound_group_sessions
819            .entry(room_id.clone())
820            .or_default()
821            .insert(session_id.to_string(), session);
822        Ok(())
823    }
824
825    fn outbound_group_session(&self, room_id: &RoomId) -> Result<Option<&[u8]>, StoreError> {
826        Ok(self.outbound_group_sessions.get(room_id).map(Vec::as_slice))
827    }
828
829    fn save_outbound_group_session(&mut self, room_id: &RoomId, session: Vec<u8>) -> Result<(), StoreError> {
830        let key = outbound_group_session_record_key(&self.device_id, room_id);
831        self.mark_dirty(key, session.clone());
832        self.outbound_group_sessions.insert(room_id.clone(), session);
833        Ok(())
834    }
835
836    fn devices_for_user(&self, user_id: &UserId) -> Result<Option<&[u8]>, StoreError> {
837        Ok(self.devices.get(user_id).map(Vec::as_slice))
838    }
839
840    fn save_devices(&mut self, user_id: &UserId, devices: Vec<u8>) -> Result<(), StoreError> {
841        let key = devices_record_key(&self.device_id, user_id);
842        self.mark_dirty(key, devices.clone());
843        self.devices.insert(user_id.clone(), devices);
844        Ok(())
845    }
846
847    fn tracked_users(&self) -> Result<Vec<(UserId, bool)>, StoreError> {
848        Ok(self.tracked_users.iter().map(|(user_id, outdated)| (user_id.clone(), *outdated)).collect())
849    }
850
851    fn mark_user_tracked(&mut self, user_id: &UserId, outdated: bool) -> Result<(), StoreError> {
852        let key = tracked_user_record_key(&self.device_id, user_id);
853        self.mark_dirty(key, vec![u8::from(outdated)]);
854        self.tracked_users.insert(user_id.clone(), outdated);
855        Ok(())
856    }
857
858    fn withheld_reason(&self, room_id: &RoomId, session_id: &str) -> Result<Option<&[u8]>, StoreError> {
859        Ok(self.withheld.get(room_id).and_then(|sessions| sessions.get(session_id)).map(Vec::as_slice))
860    }
861
862    fn save_withheld(&mut self, room_id: &RoomId, session_id: &str, code: Vec<u8>) -> Result<(), StoreError> {
863        let key = withheld_record_key(&self.device_id, room_id, session_id);
864        self.mark_dirty(key, code.clone());
865        self.withheld.entry(room_id.clone()).or_default().insert(session_id.to_string(), code);
866        Ok(())
867    }
868
869    fn backup_key(&self) -> Result<Option<&[u8]>, StoreError> {
870        Ok(self.backup_key.as_deref())
871    }
872
873    fn save_backup_key(&mut self, key: Vec<u8>) -> Result<(), StoreError> {
874        let record_key = backup_key_record_key(&self.device_id);
875        self.mark_dirty(record_key, key.clone());
876        self.backup_key = Some(key);
877        Ok(())
878    }
879
880    fn cross_signing_keys(&self) -> Result<Option<&[u8]>, StoreError> {
881        Ok(self.cross_signing_keys.as_deref())
882    }
883
884    fn save_cross_signing_keys(&mut self, keys: Vec<u8>) -> Result<(), StoreError> {
885        let key = cross_signing_keys_record_key(&self.device_id);
886        self.mark_dirty(key, keys.clone());
887        self.cross_signing_keys = Some(keys);
888        Ok(())
889    }
890}
891
892impl<C: RecordCodec> StateStore for Store<C> {
893    fn sync_token(&self) -> Result<Option<&str>, StoreError> {
894        Ok(self.sync_token.as_deref())
895    }
896
897    fn save_sync_token(&mut self, token: String) -> Result<(), StoreError> {
898        let key = sync_token_record_key(&self.device_id);
899        self.mark_dirty(key, token.clone().into_bytes());
900        self.sync_token = Some(token);
901        Ok(())
902    }
903
904    fn room_state(&self, room_id: &RoomId) -> Result<Option<&[u8]>, StoreError> {
905        Ok(self.room_state.get(room_id).map(Vec::as_slice))
906    }
907
908    fn save_room_state(&mut self, room_id: &RoomId, state: Vec<u8>) -> Result<(), StoreError> {
909        let key = room_state_record_key(&self.device_id, room_id);
910        self.mark_dirty(key, state.clone());
911        self.room_state.insert(room_id.clone(), state);
912        Ok(())
913    }
914
915    fn room_ids(&self) -> Result<Vec<RoomId>, StoreError> {
916        Ok(self.room_state.keys().cloned().collect())
917    }
918
919    fn pending_requests(&self) -> Result<Vec<PendingRequest>, StoreError> {
920        Ok(self.pending_requests.values().cloned().collect())
921    }
922
923    fn save_pending_request(&mut self, req: PendingRequest) -> Result<(), StoreError> {
924        let id = req.request.id.clone();
925        let key = pending_request_record_key(&self.device_id, &id);
926        let plaintext = serde_json::to_vec(&req)
927            .map_err(|source| StoreError::decode(&key, format!("failed to serialize pending request: {source}")))?;
928        self.mark_dirty(key, plaintext);
929        self.pending_requests.insert(id, req);
930        Ok(())
931    }
932
933    fn delete_pending_request(&mut self, id: &RequestId) -> Result<(), StoreError> {
934        if self.pending_requests.remove(id).is_some() {
935            let key = pending_request_record_key(&self.device_id, id);
936            self.mark_deleted(key);
937        }
938        Ok(())
939    }
940
941    fn counters(&self) -> Result<Option<Counters>, StoreError> {
942        Ok(self.counters)
943    }
944
945    fn save_counters(&mut self, counters: Counters) -> Result<(), StoreError> {
946        let key = counters_record_key(&self.device_id);
947        let plaintext = serde_json::to_vec(&counters)
948            .map_err(|source| StoreError::decode(&key, format!("failed to serialize counters: {source}")))?;
949        self.mark_dirty(key, plaintext);
950        self.counters = Some(counters);
951        Ok(())
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    use crate::ids::{DeviceId, RequestId, RoomId, UserId};
959    use crate::outgoing_queue::Lane;
960    use crate::wire::OutgoingRequest;
961
962    fn device_id() -> DeviceId {
963        DeviceId::parse("DEV1").expect("valid device id")
964    }
965
966    fn room_id() -> RoomId {
967        RoomId::parse("!room:example.org").expect("valid room id")
968    }
969
970    fn user_id() -> UserId {
971        UserId::parse("@alice:example.org").expect("valid user id")
972    }
973
974    /// A pending-request fixture for tests that only care about round-trip
975    /// persistence, not lane/epoch semantics (those are `outgoing_queue`'s
976    /// own tests) -- `Lane::Other`/[`RequiredSeq::NONE`] are arbitrary but
977    /// fixed choices.
978    fn pending_request(room: &RoomId) -> PendingRequest {
979        PendingRequest {
980            request: OutgoingRequest::room_members(RequestId::next(0), room),
981            lane: Lane::Other,
982            required_seq: RequiredSeq::NONE,
983        }
984    }
985
986    #[test]
987    fn in_memory_store_round_trips_account() {
988        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
989        assert_eq!(store.account().expect("no error"), None);
990
991        store.save_account(b"pickled-account".to_vec()).expect("save succeeds");
992        assert_eq!(store.account().expect("no error"), Some(b"pickled-account".as_slice()));
993    }
994
995    #[test]
996    fn in_memory_store_round_trips_pending_requests() {
997        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
998        assert!(store.pending_requests().expect("no error").is_empty());
999
1000        let req = pending_request(&room_id());
1001        store.save_pending_request(req.clone()).expect("save succeeds");
1002        assert_eq!(store.pending_requests().expect("no error"), vec![req]);
1003    }
1004
1005    #[test]
1006    fn round_trip_every_record_kind_through_load() {
1007        let device = device_id();
1008        let room = room_id();
1009        let user = user_id();
1010        let mut store = Store::new(device.clone(), InsecurePlainCodecForTests);
1011
1012        store.save_account(b"account-bytes".to_vec()).expect("save account");
1013        store
1014            .save_olm_session("curve25519-peer", "session-1", b"olm-session-bytes".to_vec())
1015            .expect("save olm session");
1016        store
1017            .save_inbound_group_session(&room, "session-1", b"megolm-in-bytes".to_vec())
1018            .expect("save inbound group session");
1019        store
1020            .save_outbound_group_session(&room, b"megolm-out-bytes".to_vec())
1021            .expect("save outbound group session");
1022        store.save_devices(&user, b"devices-bytes".to_vec()).expect("save devices");
1023        store.mark_user_tracked(&user, true).expect("mark tracked");
1024        store.save_withheld(&room, "session-1", b"withheld-bytes".to_vec()).expect("save withheld");
1025        store.save_backup_key(b"backup-bytes".to_vec()).expect("save backup key");
1026        store.save_cross_signing_keys(b"xsign-bytes".to_vec()).expect("save cross signing keys");
1027        store.save_sync_token("s123".to_string()).expect("save sync token");
1028        store.save_room_state(&room, b"room-state-bytes".to_vec()).expect("save room state");
1029        let req = pending_request(&room);
1030        store.save_pending_request(req.clone()).expect("save pending request");
1031        store.save_counters(Counters { next_request_id: 3, next_txn_id: 9 }).expect("save counters");
1032
1033        let batch = store.take_flush_batch().expect("everything above dirtied something");
1034        assert!(batch.deletes.is_empty());
1035
1036        let loaded =
1037            Store::load(batch.records, InsecurePlainCodecForTests, device).expect("load succeeds");
1038
1039        assert_eq!(loaded.account().expect("no error"), Some(b"account-bytes".as_slice()));
1040        assert_eq!(
1041            loaded.olm_sessions_for_device("curve25519-peer").expect("no error"),
1042            vec![b"olm-session-bytes".to_vec()]
1043        );
1044        assert_eq!(
1045            loaded.inbound_group_session(&room, "session-1").expect("no error"),
1046            Some(b"megolm-in-bytes".as_slice())
1047        );
1048        assert_eq!(
1049            loaded.outbound_group_session(&room).expect("no error"),
1050            Some(b"megolm-out-bytes".as_slice())
1051        );
1052        assert_eq!(loaded.devices_for_user(&user).expect("no error"), Some(b"devices-bytes".as_slice()));
1053        assert_eq!(loaded.tracked_users().expect("no error"), vec![(user.clone(), true)]);
1054        assert_eq!(
1055            loaded.withheld_reason(&room, "session-1").expect("no error"),
1056            Some(b"withheld-bytes".as_slice())
1057        );
1058        assert_eq!(loaded.backup_key().expect("no error"), Some(b"backup-bytes".as_slice()));
1059        assert_eq!(loaded.cross_signing_keys().expect("no error"), Some(b"xsign-bytes".as_slice()));
1060        assert_eq!(loaded.sync_token().expect("no error"), Some("s123"));
1061        assert_eq!(loaded.room_state(&room).expect("no error"), Some(b"room-state-bytes".as_slice()));
1062        assert_eq!(loaded.pending_requests().expect("no error"), vec![req]);
1063        assert_eq!(loaded.counters().expect("no error"), Some(Counters { next_request_id: 3, next_txn_id: 9 }));
1064    }
1065
1066    #[test]
1067    fn delete_is_exported_and_applied_on_load() {
1068        let device = device_id();
1069        let room = room_id();
1070        let mut store = Store::new(device.clone(), InsecurePlainCodecForTests);
1071
1072        let req = pending_request(&room);
1073        store.save_pending_request(req.clone()).expect("save pending request");
1074        let write_batch = store.take_flush_batch().expect("pending request save is pending");
1075        store.ack_flush(write_batch.id);
1076
1077        store.delete_pending_request(&req.request.id).expect("delete succeeds");
1078        let delete_batch = store.take_flush_batch().expect("deletion is pending");
1079        assert_eq!(delete_batch.deletes, vec![pending_request_record_key(&device, &req.request.id)]);
1080        store.ack_flush(delete_batch.id);
1081
1082        // Simulate a shell applying the delta to its own durable key-value
1083        // set: writes land, then tombstoned keys are removed.
1084        let mut durable: BTreeMap<RecordKey, Vec<u8>> =
1085            write_batch.records.into_iter().map(|record| (record.key, record.bytes)).collect();
1086        for key in delete_batch.deletes {
1087            durable.remove(&key);
1088        }
1089        let records = durable.into_iter().map(|(key, bytes)| SealedRecord { key, bytes });
1090
1091        let loaded = Store::load(records, InsecurePlainCodecForTests, device).expect("load succeeds");
1092        assert!(loaded.pending_requests().expect("no error").is_empty());
1093    }
1094
1095    #[test]
1096    fn unknown_keys_survive_a_load_and_reflush_untouched() {
1097        let device = device_id();
1098        let unknown_key = RecordKey::new(format!("messenger/{}/future_field/whatever", device.as_str()));
1099        let unknown_bytes = b"opaque-future-bytes".to_vec();
1100        let records = vec![SealedRecord { key: unknown_key.clone(), bytes: unknown_bytes.clone() }];
1101
1102        let mut store =
1103            Store::load(records, InsecurePlainCodecForTests, device).expect("unknown keys don't fail a load");
1104
1105        let batch = store.take_flush_batch().expect("the unknown record is re-adopted into the next flush");
1106        assert_eq!(batch.records.len(), 1);
1107        assert_eq!(batch.records[0].key, unknown_key);
1108        assert_eq!(batch.records[0].bytes, unknown_bytes, "re-emitted byte-for-byte, not re-sealed");
1109
1110        store.ack_flush(batch.id);
1111        let (required, extra_batch) = store.seal_for_request();
1112        assert!(extra_batch.is_none(), "nothing new became pending after the ack");
1113        assert!(store.is_released(required), "the only batch produced so far is acked");
1114    }
1115
1116    #[test]
1117    fn corrupt_record_fails_load_naming_the_key() {
1118        let device = device_id();
1119        let key = sync_token_record_key(&device);
1120        let corrupt = SealedRecord { key: key.clone(), bytes: vec![0xFF, 0xFE] };
1121
1122        let err = Store::load(vec![corrupt], InsecurePlainCodecForTests, device).unwrap_err();
1123        assert!(matches!(err, StoreError::Decode { .. }));
1124        assert!(err.to_string().contains(key.as_str()), "error names the offending key: {err}");
1125    }
1126
1127    #[test]
1128    fn malformed_known_prefix_key_is_a_hard_error() {
1129        let device = device_id();
1130        // "olm_session" is a recognized kind, but this key is missing both
1131        // the curve-key and index components.
1132        let key = RecordKey::new(format!("messenger/{}/olm_session", device.as_str()));
1133        let record = SealedRecord { key: key.clone(), bytes: b"whatever".to_vec() };
1134
1135        let err = Store::load(vec![record], InsecurePlainCodecForTests, device).unwrap_err();
1136        assert!(matches!(err, StoreError::UnknownRecordLayout { .. }));
1137        assert!(err.to_string().contains(key.as_str()));
1138    }
1139
1140    #[test]
1141    fn seal_for_request_blocks_release_until_the_sealed_batch_is_acked() {
1142        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
1143        assert!(store.is_released(RequiredSeq::NONE), "nothing dirtied yet");
1144
1145        store.save_account(b"account-bytes".to_vec()).expect("save succeeds");
1146        let (required, batch) = store.seal_for_request();
1147        let batch = batch.expect("the pending account mutation was sealed into a batch");
1148        assert!(!store.is_released(required), "sealed but not yet acked");
1149
1150        store.ack_flush(batch.id);
1151        assert!(store.is_released(required), "sealed and acked: safe to release");
1152    }
1153
1154    #[test]
1155    fn saving_a_ratcheted_session_leaves_no_older_snapshot_in_the_flush_batches_or_store() {
1156        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
1157
1158        store.save_olm_session("curve-peer", "session-1", b"pickle-v1".to_vec()).expect("save v1");
1159        let batch1 = store.take_flush_batch().expect("v1 is pending");
1160        store.ack_flush(batch1.id);
1161
1162        store.save_olm_session("curve-peer", "session-1", b"pickle-v2".to_vec()).expect("save v2, ratcheted forward");
1163        let batch2 = store.take_flush_batch().expect("v2 is pending");
1164        store.ack_flush(batch2.id);
1165
1166        fn session_keys_in(batch: &FlushBatch) -> Vec<&RecordKey> {
1167            batch.records.iter().filter(|r| r.key.as_str().contains("/olm_session/")).map(|r| &r.key).collect()
1168        }
1169        let batch1_keys = session_keys_in(&batch1);
1170        let batch2_keys = session_keys_in(&batch2);
1171        assert_eq!(batch1_keys.len(), 1, "v1's own flush carries exactly one session record");
1172        assert_eq!(batch2_keys.len(), 1, "v2's own flush carries exactly one session record");
1173        assert_eq!(batch1_keys[0], batch2_keys[0], "the second save replaced the SAME record key, not a new one");
1174        assert!(batch2.deletes.is_empty(), "no eviction yet -- only one distinct session id has ever been saved");
1175
1176        // The store itself holds only the latest bytes -- no older
1177        // snapshot survives anywhere, on disk or in memory.
1178        assert_eq!(store.olm_sessions_for_device("curve-peer").expect("no error"), vec![b"pickle-v2".to_vec()]);
1179    }
1180
1181    #[test]
1182    fn olm_sessions_per_device_are_capped() {
1183        let mut store = Store::new(device_id(), InsecurePlainCodecForTests);
1184        for i in 0..=MAX_OLM_SESSIONS_PER_DEVICE {
1185            store
1186                .save_olm_session("curve-peer", &format!("session-{i}"), format!("pickle-{i}").into_bytes())
1187                .expect("save");
1188        }
1189
1190        let sessions = store.olm_sessions_for_device("curve-peer").expect("no error");
1191        assert_eq!(sessions.len(), MAX_OLM_SESSIONS_PER_DEVICE, "capped, not accumulated without bound");
1192
1193        let evicted = b"pickle-0".to_vec();
1194        assert!(!sessions.contains(&evicted), "the least-recently-used session (the first ever saved) was evicted");
1195        let newest = format!("pickle-{MAX_OLM_SESSIONS_PER_DEVICE}").into_bytes();
1196        assert_eq!(sessions.first(), Some(&newest), "most-recently-used first");
1197
1198        let batch = store.take_flush_batch().expect("everything above is still pending");
1199        assert_eq!(batch.deletes.len(), 1, "exactly one eviction happened, producing exactly one delete");
1200    }
1201}