ping_mls_store/backend.rs
1// `PathBuf` + `Zeroizing` are only referenced by the `Sqlite` variant, which is
2// native-only. Gate the imports the same way to avoid a wasm32 unused-imports lint.
3#[cfg(not(target_arch = "wasm32"))]
4use std::path::PathBuf;
5#[cfg(not(target_arch = "wasm32"))]
6use zeroize::Zeroizing;
7
8use std::sync::Arc;
9
10use crate::AsyncBlobStore;
11
12/// Where the persistent provider checkpoints `MemoryStorage` to. Selected at
13/// `MessagingClient::init` time via [`ping_core::ClientConfig::storage_backend`].
14#[derive(Debug, Clone, Default)]
15pub enum StorageBackend {
16 /// In-memory only. Default and what tests use; loses state on process exit.
17 /// Cold-start scenarios (iOS NSE, web Service Worker) MUST use [`Self::Sqlite`]
18 /// or [`Self::IndexedDb`] instead.
19 #[default]
20 Memory,
21
22 /// SQLite-backed; native targets only. The SDK owns the file at `path`; the
23 /// parent directory must exist. Pass `encryption_key = Some(...)` to enable
24 /// SQLCipher; absence means the file is unencrypted (tests, dev only).
25 #[cfg(not(target_arch = "wasm32"))]
26 Sqlite {
27 /// Absolute path. Host convention: `<app_support>/ping/<account_id>/mls.sqlite`
28 /// for per-account isolation (CR-18); the SDK doesn't enforce this — the host
29 /// picks the path.
30 path: PathBuf,
31 /// SQLCipher key. The SDK zeroes its copy on drop; the host is responsible
32 /// for sourcing this from the OS keyring (Keychain / Keystore / etc.).
33 encryption_key: Option<Zeroizing<[u8; 32]>>,
34 },
35
36 /// SQLite-backed, opened READ-ONLY and never written back; native only.
37 ///
38 /// Exists for one job: letting a SECOND process (the iOS Notification
39 /// Service Extension) decrypt an inbound message for a lock-screen preview
40 /// while the main app remains the sole writer.
41 ///
42 /// That restriction is not caution, it is the only sound arrangement given
43 /// how this crate persists. [`Self::Sqlite`] checkpoints the ENTIRE
44 /// `MemoryStorage` map as one CBOR blob in a single row, so two writers do
45 /// not merge — the second one to flush replaces every mutation the first
46 /// made, silently discarding epochs, ratchet state, and freshly-stored key
47 /// material. Read-only removes that failure mode by construction: a reader
48 /// takes a consistent WAL snapshot, decrypts from it, and drops everything.
49 /// Nothing it touches can outlive the process, so nothing it does can be
50 /// observed by the writer.
51 ///
52 /// The cost is that the ratchet advance is thrown away, so the main app
53 /// decrypts the same message again from its own state. That is correct and
54 /// intended — the two are independent readers of the same snapshot, and
55 /// neither can desynchronise the other.
56 ///
57 /// [`PersistentMlsProvider::checkpoint`] is a NO-OP against this backend
58 /// rather than an error, so shared code paths that flush opportunistically
59 /// stay usable. Pair it with a read-only host `Storage` and
60 /// [`ping_core::MessagingClient::open_read_only`], which refuses the
61 /// state-creating paths (`LocalDevice` minting, DeviceGroup creation) that
62 /// would otherwise write through a channel this backend does not cover.
63 #[cfg(not(target_arch = "wasm32"))]
64 SqliteReadOnly {
65 /// Absolute path to a file another process owns. Must already exist —
66 /// unlike [`Self::Sqlite`], this variant never creates one, because a
67 /// created file would mean the writer's real store was not found and
68 /// silently decrypting nothing is worse than failing loudly.
69 path: PathBuf,
70 /// SQLCipher key. Same value the writing process uses; on iOS both
71 /// read it from the shared Keychain access group.
72 encryption_key: Option<Zeroizing<[u8; 32]>>,
73 },
74
75 /// Host-supplied async blob storage. Available on every target.
76 ///
77 /// The provider snapshots the entire `MemoryStorage` HashMap into a
78 /// single CBOR blob and round-trips it through the
79 /// [`AsyncBlobStore`] the host implements. This is the universal
80 /// persistence path:
81 /// * **WASM**: host wraps its IndexedDB layer (PingStorageWeb, which
82 /// already AES-GCM-encrypts every row under a non-extractable
83 /// wrap key kept inside the same IDB).
84 /// * **iOS / macOS / Android**: host wraps the same `Storage` trait
85 /// it already provides for conv metadata + cursors (typically
86 /// Keychain-encrypted SQLite or AsyncStorage), so the OpenMLS
87 /// snapshot sits under a reserved `("__mls", "snapshot")` slot
88 /// alongside the host's other persisted KV.
89 ///
90 /// Why this replaces the wasm-only `IndexedDb` variant: any host
91 /// that already implements `Storage` (which every binding does)
92 /// gets persistence for free, without per-platform path management,
93 /// Keychain key plumbing, or SQLCipher dependency. The SQLite
94 /// backend remains available for hosts that want a separate file
95 /// (e.g. iOS NSE cold-start where the main app's Storage isn't
96 /// reachable from the extension).
97 AsyncBlob { blob_store: Arc<dyn AsyncBlobStore> },
98
99 /// [`Self::AsyncBlob`] read-only — the universal-target counterpart to
100 /// [`Self::SqliteReadOnly`], for the same job on a platform with no SQLite.
101 ///
102 /// The snapshot is read through the host's blob store at open and never
103 /// written back, so a second context (a web Service Worker decrypting a
104 /// push for its notification, the mirror of the iOS NSE) can read the tab's
105 /// state without racing it. Same reasoning, same guarantee: the whole-state
106 /// blob means two writers replace rather than merge, so the reader must not
107 /// be one.
108 ///
109 /// This variant is what keeps
110 /// [`ping_core::MessagingClient::open_read_only`] a genuinely cross-platform
111 /// API rather than a native-only one — on WASM it is the only backend that
112 /// can satisfy it.
113 AsyncBlobReadOnly { blob_store: Arc<dyn AsyncBlobStore> },
114}