offline_protocol/file_store/mod.rs
1//! Built-in file-backed stores, for a host with a filesystem and no platform
2//! keystore.
3//!
4//! The engine takes its two stores from the host
5//! ([`crate::OfflineProtocol::initialize_mls`]). The mobile bindings hand in the
6//! Keychain or Keystore and an app-container directory; a headless host on
7//! Linux has neither a keystore the SDK can assume nor a binding that writes
8//! files for it. These two stores are that host's defaults:
9//!
10//! - [`FileProtocolStateStorage`] keeps restartable delivery state. It writes
11//! the same on-disk format as the Python, Swift and Kotlin providers, byte
12//! for byte, so a state directory is portable across bindings. Its values
13//! arrive already sealed by the engine for every sensitive category, exactly
14//! as they do for those providers.
15//! - [`SealedFileMlsStorage`] keeps identity keys, group state and the record
16//! key that seals the other store. It seals every record under a key derived
17//! from a *store key* the operator supplies through a [`StoreKeyProvider`],
18//! and names its files with a keyed digest.
19//!
20//! # Invariants
21//!
22//! - **A write the store acknowledged survives a crash.** Every store writes a
23//! temporary file, flushes it, renames it into place and flushes the
24//! directory. The engine writes state that depends on a returned `Ok`
25//! immediately (the record key is the sharp case), so an acknowledged write
26//! that is only staged is a data-loss bug, not a performance choice.
27//! - **A load never allocates more than the ceiling.** The file size is
28//! checked before the read and the read itself is capped, so a planted
29//! multi-gigabyte file costs a `stat`.
30//! - **File names are fixed-length digests, never an encoding of the key.**
31//! Case-insensitive volumes (APFS, NTFS by default) fold `group:Team` and
32//! `group:team` onto one file, and group ids are chosen by peers. A digest
33//! is lowercase hex whatever the key, so the key is compared byte for byte.
34//! - **The sealed store never deletes on a read.** A record that does not
35//! authenticate is reported as corrupt, with its path, and left in place.
36//! The common cause is the operator supplying the wrong store key, and
37//! deleting on that would destroy the identity.
38//! [`SealedFileMlsStorage::open`] refuses a wrong key before any record is
39//! read, so a record that fails afterwards is damaged, and the error names
40//! the file so the operator can decide.
41//! - **One store per directory.** Each store holds an exclusive lock on its
42//! account directory until it is closed or dropped, and a second store over
43//! the same directory, in this process or another, is refused with
44//! [`FileStoreError::InUse`]. Two stores over one MLS directory would each
45//! advance ratchet state the other never sees, and the groups would diverge
46//! without an error.
47//! - **Releasing a directory never depends on who still holds the store.**
48//! `close` releases the lock at once and makes every later operation an
49//! error. A host whose runtime decides when an object is freed (a garbage
50//! collector, a reference cycle through a callback) would otherwise hold
51//! the directory for as long as anything, anywhere, kept a reference.
52//!
53//! # Layout
54//!
55//! Each store owns `<root>/<namespace>/`, where the namespace is
56//! [`account_storage_namespace`] of the application id and profile, the same
57//! derivation every binding uses. The two stores use different directory
58//! prefixes and different lock files, so they do not collide if given one
59//! root, but they should not share one: protocol state is scoped to an
60//! installation and is meant to be removed with it, while MLS material may
61//! outlive it.
62
63mod key;
64mod mls;
65mod pair;
66mod records;
67mod state;
68
69pub use key::{EnvStoreKey, StaticStoreKey, StoreKeyError, StoreKeyProvider, STORE_KEY_BYTES};
70pub use mls::SealedFileMlsStorage;
71pub use pair::FileStorePair;
72pub use state::{FileProtocolStateStorage, SealedState};
73
74pub use crate::storage_namespace::{account_storage_namespace, is_account_storage_namespace};
75
76use std::path::PathBuf;
77
78/// Why a built-in file store could not be opened, or could not answer a
79/// question about its directory.
80///
81/// Opening and the probes are the only steps with their own error type: a
82/// record operation reports through the error type of the trait the store
83/// implements.
84#[non_exhaustive]
85#[derive(Debug, thiserror::Error)]
86pub enum FileStoreError {
87 /// The namespace is not the output of [`account_storage_namespace`].
88 #[error("invalid storage namespace: expected `account-` and 64 lowercase hex digits")]
89 InvalidNamespace,
90 /// The store directory could not be created or read.
91 #[error("store directory {path} is unusable: {source}")]
92 Io {
93 /// The directory the store tried to use.
94 path: PathBuf,
95 /// The underlying failure.
96 source: std::io::Error,
97 },
98 /// The store key provider did not produce a key.
99 #[error("store key unavailable: {0}")]
100 StoreKey(#[from] StoreKeyError),
101 /// The store key does not open this store's records.
102 ///
103 /// Nothing was read, changed or deleted. The usual cause is the operator
104 /// supplying a different key than the one the store was created with.
105 #[error("the store key does not open the store at {0}; nothing was changed")]
106 WrongStoreKey(PathBuf),
107 /// The key check file does not open, and the store holds no record that
108 /// could prove the key either way.
109 ///
110 /// Nothing was read, changed or deleted. Either the key is wrong or the
111 /// check file is damaged, and an empty store cannot tell the two apart.
112 /// With nothing sealed to lose, removing the check file named here lets
113 /// the next open start the store afresh under the key offered.
114 #[error(
115 "the store key check at {0} does not open and the store has no record to prove \
116 the key; nothing was changed. If this is the right key, remove that file to \
117 start the store afresh"
118 )]
119 KeyCheckUnverifiable(PathBuf),
120 /// Another store already has this directory open, in this process or in
121 /// another one.
122 ///
123 /// Nothing was read or changed. Two stores over one directory would
124 /// diverge the MLS state, so the second is refused rather than guarded.
125 #[error(
126 "the store at {0} is already open elsewhere; two stores over one directory are refused"
127 )]
128 InUse(PathBuf),
129 /// The store was closed, and holds its directory no longer.
130 #[error("the store at {0} is closed")]
131 Closed(PathBuf),
132 /// The MLS store could not say whether it holds the key that seals
133 /// protocol state.
134 ///
135 /// Nothing was changed. This is a read that failed, not a key that is
136 /// missing or damaged: those are answers, and are reported as
137 /// [`SealedState`].
138 #[error("the protocol-state record key could not be read: {0}; nothing was changed")]
139 RecordKeyUnreadable(String),
140 /// [`FileStorePair::open`] was given one directory for both roots, or
141 /// one inside the other.
142 ///
143 /// The MLS root holds the identity and must survive the removal of the
144 /// state root, which is scoped to the installation. When
145 /// `on_this_volume` is set, the roots are spelled apart and are one
146 /// directory only on this volume (one that folds case), which shows only
147 /// once both exist: the stores' directories were created then, and no
148 /// record was written.
149 #[error("{}", roots_overlap_message(.mls_root, .state_root, .on_this_volume))]
150 RootsOverlap {
151 /// The MLS root as given.
152 mls_root: PathBuf,
153 /// The protocol-state root as given.
154 state_root: PathBuf,
155 /// Found only once both directories existed.
156 on_this_volume: bool,
157 },
158 /// The protocol-state account directory holds records and the MLS root
159 /// holds no identity for the account.
160 ///
161 /// Nothing was created. Opening would mint a new identity (a new
162 /// address) whose record key cannot unseal those records, and the first
163 /// restore would delete the ones it read: a deployment moving onto the
164 /// file stores with a kept state root, or one whose MLS root was lost.
165 #[error(
166 "{} holds protocol state, but {} holds no identity for this account: the file \
167 stores would start a new identity (a new address) and delete the state it cannot \
168 unseal. Point state_root at a fresh directory, or restore the mls_root that wrote \
169 this state",
170 .state.display(),
171 .mls_root.display()
172 )]
173 StateWithoutIdentity {
174 /// The protocol-state account directory.
175 state: PathBuf,
176 /// The MLS root.
177 mls_root: PathBuf,
178 },
179 /// The protocol-state account directory belongs to another MLS store:
180 /// it is bound to another one, or its sealed records open under no
181 /// record key this one holds.
182 ///
183 /// No record was changed. Handing the pair to an engine would delete the
184 /// queued and parked messages its restore reads.
185 #[error(
186 "{} holds protocol state that another identity wrote, not the one in {}: starting \
187 would delete the queued and parked messages in it. Use the mls_root that wrote this \
188 state, or point state_root at a fresh directory. No record was changed",
189 .state.display(),
190 .mls_root.display()
191 )]
192 ForeignState {
193 /// The protocol-state account directory.
194 state: PathBuf,
195 /// The MLS root.
196 mls_root: PathBuf,
197 },
198}
199
200fn roots_overlap_message(
201 mls_root: &std::path::Path,
202 state_root: &std::path::Path,
203 on_this_volume: &bool,
204) -> String {
205 if *on_this_volume {
206 format!(
207 "mls_root and state_root must be different directories, neither inside the \
208 other: {} and {} are spelled apart, and on this volume one is the other or lies \
209 inside it. The stores' directories were created; no record was written or changed",
210 mls_root.display(),
211 state_root.display()
212 )
213 } else {
214 "mls_root and state_root must be different directories, neither inside the other: \
215 mls_root holds the identity and must survive the removal of state_root"
216 .to_string()
217 }
218}
219
220fn account_directory(root: &std::path::Path, namespace: &str) -> Result<PathBuf, FileStoreError> {
221 if !is_account_storage_namespace(namespace) {
222 return Err(FileStoreError::InvalidNamespace);
223 }
224 let directory = root.join(namespace);
225 records::private_mkdir(&directory).map_err(|source| FileStoreError::Io {
226 path: directory.clone(),
227 source,
228 })?;
229 Ok(directory)
230}
231
232/// Whether the account directory `<root>/<namespace>/` holds an entry that
233/// `counts` accepts, without creating, locking or changing anything.
234///
235/// A directory that is not there holds nothing. One that cannot be read is
236/// an error, never "empty": a caller deciding whether it may start a new
237/// identity over it must not take a permission error for a fresh install.
238/// `counts` is handed the entry's path and name, and may look inside it; an
239/// error it returns is reported against the path it was given.
240fn account_holds(
241 root: &std::path::Path,
242 namespace: &str,
243 counts: impl Fn(&std::path::Path, &str) -> std::io::Result<bool>,
244) -> Result<bool, FileStoreError> {
245 if !is_account_storage_namespace(namespace) {
246 return Err(FileStoreError::InvalidNamespace);
247 }
248 let directory = root.join(namespace);
249 let unusable = |path: &std::path::Path, source: std::io::Error| FileStoreError::Io {
250 path: path.to_path_buf(),
251 source,
252 };
253 let entries = match std::fs::read_dir(&directory) {
254 Ok(entries) => entries,
255 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
256 Err(err) => return Err(unusable(&directory, err)),
257 };
258 for entry in entries {
259 let entry = entry.map_err(|err| unusable(&directory, err))?;
260 let path = entry.path();
261 if counts(&path, &entry.file_name().to_string_lossy())
262 .map_err(|err| unusable(&path, err))?
263 {
264 return Ok(true);
265 }
266 }
267 Ok(false)
268}
269
270/// Takes the store's exclusive lock on `directory`.
271fn lock_directory(
272 directory: &std::path::Path,
273 lock_name: &str,
274) -> Result<records::DirectoryLock, FileStoreError> {
275 records::DirectoryLock::acquire(directory, lock_name).map_err(|err| match err {
276 records::LockError::Held => FileStoreError::InUse(directory.to_path_buf()),
277 records::LockError::Io(source) => FileStoreError::Io {
278 path: directory.join(lock_name),
279 source,
280 },
281 })
282}
283
284#[cfg(test)]
285pub(crate) mod test_support {
286 use std::path::{Path, PathBuf};
287
288 use rand_core::{OsRng, RngCore};
289
290 /// A unique directory under the system temp dir, removed on drop.
291 pub(crate) struct TempRoot(PathBuf);
292
293 impl TempRoot {
294 pub(crate) fn new(label: &str) -> Self {
295 let mut suffix = [0u8; 8];
296 OsRng.fill_bytes(&mut suffix);
297 let path = std::env::temp_dir()
298 .join(format!("offline-protocol-{label}-{}", hex::encode(suffix)));
299 std::fs::create_dir_all(&path).expect("create temp root");
300 Self(path)
301 }
302
303 pub(crate) fn path(&self) -> &Path {
304 &self.0
305 }
306 }
307
308 impl Drop for TempRoot {
309 fn drop(&mut self) {
310 let _ = std::fs::remove_dir_all(&self.0);
311 }
312 }
313
314 /// Every regular file under `dir`, recursively.
315 pub(crate) fn files_under(dir: &Path) -> Vec<PathBuf> {
316 let mut out = Vec::new();
317 let mut stack = vec![dir.to_path_buf()];
318 while let Some(next) = stack.pop() {
319 let Ok(entries) = std::fs::read_dir(&next) else {
320 continue;
321 };
322 for entry in entries.flatten() {
323 let path = entry.path();
324 if path.is_dir() {
325 stack.push(path);
326 } else {
327 out.push(path);
328 }
329 }
330 }
331 out.sort();
332 out
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 /// The vector the Python binding pins in `test_storage_namespace.py`; the
341 /// Swift and Kotlin helpers share the derivation. A Rust store that
342 /// derived anything else would open an empty directory for an account a
343 /// binding had already written.
344 #[test]
345 fn the_namespace_matches_the_bindings() {
346 assert_eq!(
347 account_storage_namespace("test-app", "test-user-1"),
348 "account-814873e0cbdb2a1f25f14b31625e7f904cf9923e55b415b91ca4b29b210c12a1"
349 );
350 assert_ne!(
351 account_storage_namespace("chat", "alice"),
352 account_storage_namespace("chat", "bob")
353 );
354 assert_ne!(
355 account_storage_namespace("chat", "alice"),
356 account_storage_namespace("other-chat", "alice")
357 );
358 }
359
360 #[test]
361 fn only_the_derived_shape_is_a_namespace() {
362 assert!(is_account_storage_namespace(&account_storage_namespace(
363 "chat", "alice"
364 )));
365 let a64 = "a".repeat(64);
366 for bad in [
367 String::new(),
368 "account-".to_string(),
369 "../../other-account".to_string(),
370 format!("account-{}", "a".repeat(63)),
371 format!("account-{}", "a".repeat(65)),
372 format!("account-{}", "A".repeat(64)),
373 format!("account-{}", "g".repeat(64)),
374 format!("account-{a64}/.."),
375 format!("Account-{a64}"),
376 format!("account-{}\u{0}", "a".repeat(63)),
377 ] {
378 assert!(!is_account_storage_namespace(&bad), "{bad:?} accepted");
379 }
380 }
381
382 #[test]
383 fn a_bad_namespace_is_refused_before_anything_is_created() {
384 let root = test_support::TempRoot::new("namespace");
385 let result = FileProtocolStateStorage::open(root.path(), "../escape");
386 assert!(matches!(result, Err(FileStoreError::InvalidNamespace)));
387 assert!(test_support::files_under(root.path()).is_empty());
388 assert_eq!(
389 std::fs::read_dir(root.path()).expect("read root").count(),
390 0,
391 "a refused namespace must not create a directory"
392 );
393 }
394}