Skip to main content

offline_protocol/file_store/
state.rs

1//! [`FileProtocolStateStorage`]: the bindings' protocol-state file format,
2//! written from Rust.
3
4use std::collections::BTreeSet;
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest, Sha256};
8
9use offline_protocol_mls::storage::MlsStorage;
10
11use super::pair::PAIRING_ID_BYTES;
12use super::records::{self, BoundedRead, StoreLock, SweepMemo, MAX_FRAME_BYTES, MAX_HEADER_BYTES};
13use super::{account_directory, lock_directory, FileStoreError};
14use crate::protocol::state_crypto::StateRecordCipher;
15use crate::protocol::{sealed_state_key_types, stored_state_record_key, StoredRecordKey};
16use crate::protocol_state_storage::{
17    ProtocolStateError, ProtocolStateResult, ProtocolStateStorage,
18};
19
20/// Prefix of a type directory: `t_` and the digest of the key type.
21const TYPE_PREFIX: &str = "t_";
22
23/// Prefix of a record file: `k_` and the digest of the key type and id.
24const ENTRY_PREFIX: &str = "k_";
25
26/// The lock file this store holds in its account directory. Distinct from
27/// the sealed store's, so the two can share a root.
28const LOCK_FILE: &str = "state-store.lock";
29
30/// The pairing id of the MLS store this state belongs with, in the clear. In
31/// the account directory, outside every type directory, so
32/// [`FileProtocolStateStorage::holds_records`] never takes it for state, and
33/// the binding providers, which only read type directories, never see it.
34const PAIRING_FILE: &str = "state-store.pair";
35
36/// Sealed records [`FileProtocolStateStorage::sealed_state`] tries in each
37/// category before it moves to the next. One that opens settles the whole
38/// question, so the ceiling is only reached in a category none of whose
39/// records open. Per category, not overall: damage confined to one category
40/// must not speak for a store whose other categories open.
41const SEALED_STATE_PROBE_LIMIT: usize = 64;
42
43/// What the record key an MLS store holds makes of the sealed records in a
44/// protocol-state store.
45///
46/// The engine seals protocol state under a key it keeps in the MLS store, and
47/// a record that does not open is deleted as the restore reaches it: a
48/// record nothing can read is a loss to settle, not a read to retry. That is
49/// right for one damaged record and wrong for a store sealed under another
50/// identity's key, where the first restore deletes the queued and parked
51/// messages of an identity that is merely somewhere else. This is the
52/// question to ask before the engine is handed the pair.
53///
54/// It is asked of the categories the engine seals. The telemetry queue is
55/// sealed under the same key and is not examined: a batch that does not open
56/// is the uploader's to drop, and no message is lost with it.
57///
58/// The records alone cannot tell another identity's state from this
59/// identity's state after the engine regenerated a damaged record key. The
60/// launch that regenerates it cannot delete everything sealed under the lost
61/// key (documents are read when they are opened, and each restore walk stops
62/// at its delete allowance), so every later launch finds records that open
63/// under no key the MLS store holds, and this answers `Foreign`. Open the two
64/// stores with [`crate::FileStorePair`], which records which MLS store a state
65/// store belongs with and asks the records only of a state store never bound.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum SealedState {
69    /// No record in any category the engine seals.
70    Empty,
71    /// A sealed record opens under the MLS store's record key: the two
72    /// stores belong together.
73    Opens,
74    /// Sealed records exist, in no category does one of those tried open,
75    /// and the MLS store holds a usable record key or none at all: they were
76    /// sealed under another key, another identity's or one this identity
77    /// lost and replaced (see above). The engine deletes the ones its restore
78    /// reads, queued and parked messages among them.
79    Foreign,
80    /// Sealed records exist and the MLS store's record key is damaged, so
81    /// nothing will open them again, whoever sealed them. Refusing the pair
82    /// saves nothing. The engine generates a new key and settles what its
83    /// restore reads as failed, which is how the application learns which
84    /// messages were lost.
85    KeyDamaged,
86}
87
88/// What [`FileProtocolStateStorage::probe_category`] found in one category.
89enum SealedCategory {
90    Empty,
91    Opens,
92    NoneOpens,
93}
94
95/// Protocol-state storage in plain files, in the format the Python, Swift and
96/// Kotlin providers write.
97///
98/// A record lives at `<root>/<namespace>/t_<sha256(type)>/k_<sha256(type, id)>`
99/// and carries its own key in a header (see the module docs), so every file is
100/// attributable and a read verifies it opened the record it asked for. The
101/// directory is portable: a store written by the Python binding opens here for
102/// the same account, and the reverse.
103///
104/// Values are stored verbatim. The engine seals every category that can carry
105/// message plaintext or key material before it reaches this trait, so those
106/// arrive as ciphertext under the record key held in the MLS store. Record
107/// *keys* are peer and message ids and are not sealed; the file names are an
108/// unsalted digest of them, so anyone who can list the directory can confirm a
109/// guessed id. Directories are created owner-only for that reason.
110pub struct FileProtocolStateStorage {
111    directory: PathBuf,
112    lock: StoreLock,
113    swept: SweepMemo,
114}
115
116impl std::fmt::Debug for FileProtocolStateStorage {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("FileProtocolStateStorage")
119            .field("directory", &self.directory)
120            .finish_non_exhaustive()
121    }
122}
123
124fn digest(components: &[&str]) -> String {
125    let mut sha = Sha256::new();
126    for component in components {
127        sha.update(component.as_bytes());
128        sha.update([0u8]);
129    }
130    hex::encode(sha.finalize())
131}
132
133fn type_directory_name(key_type: &str) -> String {
134    format!("{TYPE_PREFIX}{}", digest(&[key_type]))
135}
136
137fn entry_name(key_type: &str, key_id: &str) -> String {
138    format!("{ENTRY_PREFIX}{}", digest(&[key_type, key_id]))
139}
140
141impl FileProtocolStateStorage {
142    /// Opens (creating if needed) the store at `<root>/<namespace>/`.
143    ///
144    /// `namespace` must be [`super::account_storage_namespace`] of the
145    /// application id and profile; anything else is refused before a
146    /// directory is created. `root` should be a directory the host removes
147    /// when the installation is removed: protocol state is scoped to an
148    /// installation, and a store that outlives it resends settled work.
149    ///
150    /// A second store over the same directory is refused with
151    /// [`FileStoreError::InUse`] until this one is closed or dropped.
152    pub fn open(root: impl AsRef<Path>, namespace: &str) -> Result<Self, FileStoreError> {
153        let directory = account_directory(root.as_ref(), namespace)?;
154        let directory_lock = lock_directory(&directory, LOCK_FILE)?;
155        Ok(Self {
156            directory,
157            lock: StoreLock::new(directory_lock),
158            swept: SweepMemo::default(),
159        })
160    }
161
162    /// Releases the directory and refuses every later operation.
163    ///
164    /// The lock goes at once, whoever still holds the store. An operation in
165    /// flight finishes first. Afterwards every operation is an error naming
166    /// the directory, and a new store may open over it. Closing twice is
167    /// closing once.
168    pub fn close(&self) {
169        self.lock.close();
170    }
171
172    /// What the record key `secure` holds makes of the sealed records here.
173    ///
174    /// Read-only on both stores: nothing is deleted, and no record key is
175    /// generated. A record whose framing does not parse is passed over, since
176    /// it says nothing about any key. A record that cannot be read at all is
177    /// an [`FileStoreError::Io`], and a record key the MLS store cannot read
178    /// (as opposed to one it does not hold, or holds damaged) is an
179    /// [`FileStoreError::RecordKeyUnreadable`]: neither is taken for an
180    /// answer.
181    pub fn sealed_state(&self, secure: &dyn MlsStorage) -> Result<SealedState, FileStoreError> {
182        let _guard = self
183            .lock
184            .open()
185            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
186        let key = stored_state_record_key(secure)
187            .map_err(|err| FileStoreError::RecordKeyUnreadable(err.to_string()))?;
188        let cipher = match &key {
189            StoredRecordKey::Usable(cipher) => Some(cipher),
190            StoredRecordKey::Absent | StoredRecordKey::Unrecoverable(_) => None,
191        };
192        let mut sealed_records = false;
193        for key_type in sealed_state_key_types() {
194            match self.probe_category(key_type, cipher)? {
195                SealedCategory::Empty => {}
196                SealedCategory::Opens => return Ok(SealedState::Opens),
197                SealedCategory::NoneOpens => sealed_records = true,
198            }
199        }
200        Ok(match key {
201            _ if !sealed_records => SealedState::Empty,
202            StoredRecordKey::Unrecoverable(_) => SealedState::KeyDamaged,
203            StoredRecordKey::Usable(_) | StoredRecordKey::Absent => SealedState::Foreign,
204        })
205    }
206
207    /// Tries the records of one sealed category against `cipher`, up to the
208    /// ceiling, stopping at the first that opens. Without a cipher nothing
209    /// can open, so one record is enough to know the category is not empty.
210    fn probe_category(
211        &self,
212        key_type: &str,
213        cipher: Option<&StateRecordCipher>,
214    ) -> Result<SealedCategory, FileStoreError> {
215        let directory = self.type_directory(key_type);
216        let ceiling = if cipher.is_some() {
217            SEALED_STATE_PROBE_LIMIT
218        } else {
219            1
220        };
221        let mut tried = 0usize;
222        let mut opened = false;
223        let mut unreadable = None;
224        records::for_each_entry(
225            &directory,
226            ENTRY_PREFIX,
227            records::MAX_LISTED_ENTRIES,
228            |path, _| {
229                if opened || unreadable.is_some() || tried >= ceiling {
230                    return;
231                }
232                let raw = match records::read_bounded(path, MAX_FRAME_BYTES) {
233                    Ok(BoundedRead::Bytes(raw)) => raw,
234                    Ok(BoundedRead::Absent | BoundedRead::Oversized) => return,
235                    Err(source) => {
236                        unreadable = Some(FileStoreError::Io {
237                            path: path.to_path_buf(),
238                            source,
239                        });
240                        return;
241                    }
242                };
243                let Some(header) = records::parse_header(&raw) else {
244                    return;
245                };
246                if header.key_type != key_type {
247                    return;
248                }
249                tried += 1;
250                opened = cipher.is_some_and(|cipher| {
251                    cipher
252                        .open(key_type, &header.key_id, &raw[header.value_offset..])
253                        .is_some()
254                });
255            },
256        )
257        .map_err(|source| FileStoreError::Io {
258            path: directory.clone(),
259            source,
260        })?;
261        if let Some(err) = unreadable {
262            return Err(err);
263        }
264        Ok(match (opened, tried) {
265            (true, _) => SealedCategory::Opens,
266            (false, 0) => SealedCategory::Empty,
267            (false, _) => SealedCategory::NoneOpens,
268        })
269    }
270
271    /// The pairing id this store was bound to, or `None` for a store never
272    /// bound (written by an engine over stores opened one by one, or by a
273    /// binding's provider). A file of the wrong length names no MLS store,
274    /// and reads as `None`.
275    pub(super) fn pairing(&self) -> Result<Option<[u8; PAIRING_ID_BYTES]>, FileStoreError> {
276        let _guard = self
277            .lock
278            .open()
279            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
280        let path = self.directory.join(PAIRING_FILE);
281        match records::read_bounded(&path, PAIRING_ID_BYTES)
282            .map_err(|source| FileStoreError::Io { path, source })?
283        {
284            BoundedRead::Bytes(raw) if raw.len() == PAIRING_ID_BYTES => {
285                let mut id = [0u8; PAIRING_ID_BYTES];
286                id.copy_from_slice(&raw);
287                Ok(Some(id))
288            }
289            BoundedRead::Bytes(_) | BoundedRead::Absent | BoundedRead::Oversized => Ok(None),
290        }
291    }
292
293    /// Binds this store to the MLS store whose pairing id is `id`, durably.
294    pub(super) fn set_pairing(&self, id: &[u8; PAIRING_ID_BYTES]) -> Result<(), FileStoreError> {
295        let _guard = self
296            .lock
297            .open()
298            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
299        #[cfg(test)]
300        if tests::FAIL_PAIRING_WRITE.with(|fail| fail.replace(false)) {
301            return Err(FileStoreError::Io {
302                path: self.directory.join(PAIRING_FILE),
303                source: std::io::Error::other("injected by a test"),
304            });
305        }
306        // The only file written into the account directory, so a crash
307        // mid-write orphans its temporary here, where no record write sweeps.
308        self.swept.sweep_once(&self.directory);
309        let path = self.directory.join(PAIRING_FILE);
310        records::write_atomic(&self.directory, &path, id)
311            .map_err(|source| FileStoreError::Io { path, source })
312    }
313
314    /// What a closed store answers.
315    fn closed(&self) -> String {
316        format!("the store at {} is closed", self.directory.display())
317    }
318
319    /// The directory this store owns.
320    pub fn directory(&self) -> &Path {
321        &self.directory
322    }
323
324    /// Whether `<root>/<namespace>/` holds protocol state, without opening,
325    /// creating or locking anything.
326    ///
327    /// A record file in a type directory, which is where this store and
328    /// every binding's provider put a record. Not the lock file an open
329    /// leaves, not a type directory every record has since been deleted
330    /// from, and not what the MLS store leaves in a directory the two were
331    /// wrongly made to share: none of those is state a new identity could
332    /// strand. A directory that cannot be read, at either level, is an
333    /// [`FileStoreError::Io`], never "no state".
334    pub fn holds_records(root: impl AsRef<Path>, namespace: &str) -> Result<bool, FileStoreError> {
335        super::account_holds(root.as_ref(), namespace, |path, name| {
336            if !name.starts_with(TYPE_PREFIX) {
337                return Ok(false);
338            }
339            // `metadata`, not `is_dir`: see `SealedFileMlsStorage::holds_records`.
340            if !std::fs::metadata(path)?.is_dir() {
341                return Ok(false);
342            }
343            records::holds_entry(path, ENTRY_PREFIX)
344        })
345    }
346
347    fn type_directory(&self, key_type: &str) -> PathBuf {
348        self.directory.join(type_directory_name(key_type))
349    }
350
351    fn entry_path(&self, key_type: &str, key_id: &str) -> PathBuf {
352        self.type_directory(key_type)
353            .join(entry_name(key_type, key_id))
354    }
355
356    /// Removes a record that can never be read and returns the error saying
357    /// so. `Corrupted`, not `Ok(None)`: the engine settles the message ids the
358    /// application holds for a destroyed record, while absence is reported to
359    /// no one.
360    fn discard(path: &Path, reason: &str) -> ProtocolStateError {
361        tracing::warn!(path = %path.display(), reason, "dropping protocol-state record");
362        let _ = records::remove(path);
363        ProtocolStateError::Corrupted(format!(
364            "dropped unreadable protocol-state record: {reason}"
365        ))
366    }
367}
368
369impl ProtocolStateStorage for FileProtocolStateStorage {
370    fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()> {
371        let framed = records::frame(key_type, key_id, data)
372            .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))?;
373        let _guard = self
374            .lock
375            .open()
376            .ok_or_else(|| ProtocolStateError::StoreFailed(self.closed()))?;
377        let directory = self.type_directory(key_type);
378        records::private_mkdir(&directory)
379            .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))?;
380        self.swept.sweep_once(&directory);
381        records::write_atomic(&directory, &self.entry_path(key_type, key_id), &framed)
382            .map_err(|err| ProtocolStateError::StoreFailed(err.to_string()))
383    }
384
385    fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>> {
386        let _guard = self
387            .lock
388            .open()
389            .ok_or_else(|| ProtocolStateError::LoadFailed(self.closed()))?;
390        let path = self.entry_path(key_type, key_id);
391        let raw = match records::read_bounded(&path, MAX_FRAME_BYTES)
392            .map_err(|err| ProtocolStateError::LoadFailed(err.to_string()))?
393        {
394            BoundedRead::Absent => return Ok(None),
395            BoundedRead::Oversized => {
396                return Err(Self::discard(&path, "record is over the ceiling"))
397            }
398            BoundedRead::Bytes(raw) => raw,
399        };
400        match records::parse_header(&raw) {
401            Some(header) if header.key_type == key_type && header.key_id == key_id => {
402                let mut raw = raw;
403                raw.drain(..header.value_offset);
404                Ok(Some(raw))
405            }
406            // Malformed framing, or a name that resolves to some other
407            // record: either way this is not the entry that was asked for.
408            _ => Err(Self::discard(
409                &path,
410                "record framing does not name the requested key",
411            )),
412        }
413    }
414
415    fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()> {
416        let _guard = self
417            .lock
418            .open()
419            .ok_or_else(|| ProtocolStateError::DeleteFailed(self.closed()))?;
420        records::remove(&self.entry_path(key_type, key_id))
421            .map(|_| ())
422            .map_err(|err| ProtocolStateError::DeleteFailed(err.to_string()))
423    }
424
425    fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>> {
426        let _guard = self
427            .lock
428            .open()
429            .ok_or_else(|| ProtocolStateError::LoadFailed(self.closed()))?;
430        // Deduped: a planted copy of a record under another name must not
431        // make its id appear twice.
432        let mut keys = BTreeSet::new();
433        records::for_each_entry(
434            &self.type_directory(key_type),
435            ENTRY_PREFIX,
436            records::MAX_LISTED_ENTRIES,
437            |path, _| {
438                if let Some(header) = records::read_prefix(path, MAX_HEADER_BYTES)
439                    .as_deref()
440                    .and_then(records::parse_header)
441                {
442                    if header.key_type == key_type {
443                        keys.insert(header.key_id);
444                    }
445                }
446            },
447        )
448        .map_err(|err| ProtocolStateError::LoadFailed(err.to_string()))?;
449        Ok(keys.into_iter().collect())
450    }
451}
452
453#[cfg(test)]
454pub(super) mod tests {
455    use super::*;
456    use crate::file_store::account_storage_namespace;
457    use crate::file_store::records::MAX_VALUE_BYTES;
458    use crate::file_store::test_support::TempRoot;
459    use offline_protocol_mls::storage::{StorageError, StorageResult};
460
461    thread_local! {
462        /// Set by a test to make this thread's next pairing write fail, as a
463        /// full disk or a crash would.
464        pub(in crate::file_store) static FAIL_PAIRING_WRITE: std::cell::Cell<bool> =
465            const { std::cell::Cell::new(false) };
466    }
467
468    fn namespace() -> String {
469        account_storage_namespace("com.example.state", "alice")
470    }
471
472    fn open(root: &TempRoot) -> FileProtocolStateStorage {
473        FileProtocolStateStorage::open(root.path(), &namespace()).expect("open")
474    }
475
476    /// Only a record file is state. Not the lock file an open leaves, not a
477    /// type directory emptied since, and not what the MLS store leaves in a
478    /// directory the two were wrongly made to share: taking any of those for
479    /// state refuses a fresh identity over a directory that holds nothing to
480    /// lose. The probe creates nothing.
481    #[test]
482    fn holds_records_counts_record_files_and_nothing_else() {
483        let root = TempRoot::new("state-holds");
484        let probe = || FileProtocolStateStorage::holds_records(root.path(), &namespace());
485        assert!(!probe().expect("absent"));
486        assert!(
487            !root.path().join(namespace()).exists(),
488            "the probe creates nothing"
489        );
490        let store = open(&root);
491        assert!(!probe().expect("lock file only"));
492
493        for stray in ["mls-store.lock", "store-key-check", "t_not-a-directory"] {
494            std::fs::write(store.directory().join(stray), b"").expect("stray file");
495        }
496        std::fs::create_dir(store.directory().join("c_an-mls-type-directory")).expect("mls");
497        assert!(!probe().expect("what another store left"));
498
499        store.store("outbox", "one", b"sealed").expect("store");
500        store.delete("outbox", "one").expect("delete");
501        assert!(store.type_directory("outbox").is_dir());
502        assert!(!probe().expect("a type directory with no record left in it"));
503
504        store
505            .store("lamport_clock", "current", b"1")
506            .expect("store");
507        assert!(probe().expect("a record"));
508    }
509
510    /// Closing releases the directory while the store is still held. What
511    /// the closed store then answers is a failed operation, never
512    /// `Corrupted`: the engine settles a corrupt record as a message that
513    /// failed for good.
514    #[test]
515    fn a_closed_store_releases_the_directory_and_refuses_every_operation() {
516        let root = TempRoot::new("state-close");
517        let store = open(&root);
518        store.store("outbox", "one", b"kept").expect("store");
519        assert!(matches!(
520            FileProtocolStateStorage::open(root.path(), &namespace()),
521            Err(FileStoreError::InUse(_))
522        ));
523
524        store.close();
525        store.close();
526        let next = open(&root);
527        assert_eq!(
528            next.load("outbox", "one").expect("load"),
529            Some(b"kept".to_vec())
530        );
531
532        assert!(matches!(
533            store.store("outbox", "one", b"other"),
534            Err(ProtocolStateError::StoreFailed(m)) if m.contains("closed")
535        ));
536        assert!(matches!(
537            store.load("outbox", "one"),
538            Err(ProtocolStateError::LoadFailed(m)) if m.contains("closed")
539        ));
540        assert!(matches!(
541            store.delete("outbox", "one"),
542            Err(ProtocolStateError::DeleteFailed(m)) if m.contains("closed")
543        ));
544        assert!(matches!(
545            store.list_keys("outbox"),
546            Err(ProtocolStateError::LoadFailed(m)) if m.contains("closed")
547        ));
548        assert!(matches!(
549            store.sealed_state(&NoRecordKey),
550            Err(FileStoreError::Closed(_))
551        ));
552        assert_eq!(
553            next.load("outbox", "one").expect("load"),
554            Some(b"kept".to_vec()),
555            "a closed store changes nothing in a directory it no longer holds"
556        );
557    }
558
559    /// A secure store that holds no record key, one that cannot say, one
560    /// that holds a given key, and one whose key record is damaged.
561    struct NoRecordKey;
562    struct UnreadableRecordKey;
563    struct RecordKey([u8; 32]);
564    struct DamagedRecordKey;
565
566    impl MlsStorage for RecordKey {
567        fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
568            Ok(())
569        }
570        fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
571            Ok(Some(self.0.to_vec()))
572        }
573        fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
574            Ok(())
575        }
576        fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
577            Ok(Vec::new())
578        }
579    }
580
581    impl MlsStorage for DamagedRecordKey {
582        fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
583            Ok(())
584        }
585        fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
586            Err(StorageError::CorruptedData(
587                "the record does not authenticate".to_string(),
588            ))
589        }
590        fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
591            Ok(())
592        }
593        fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
594            Ok(Vec::new())
595        }
596    }
597
598    /// Seals `count` records of `key_type` under `key`, as the engine does.
599    fn seal_records(store: &FileProtocolStateStorage, key: [u8; 32], key_type: &str, count: usize) {
600        let cipher = StateRecordCipher::new(&key);
601        for index in 0..count {
602            let key_id = format!("record-{index}");
603            let sealed = cipher.seal(key_type, &key_id, b"parked").expect("seal");
604            store.store(key_type, &key_id, &sealed).expect("store");
605        }
606    }
607
608    /// The ceiling is per category. Damage that fills one category (or a
609    /// category another key sealed) must not speak for a store whose other
610    /// categories open: the engine would drop those records and keep the
611    /// rest, and a verdict of `Foreign` would refuse the whole store.
612    #[test]
613    fn records_that_do_not_open_in_one_category_do_not_speak_for_the_store() {
614        let root = TempRoot::new("state-per-category");
615        let store = open(&root);
616        let mut sealed_types = sealed_state_key_types();
617        let (first, second) = (
618            sealed_types.next().expect("a sealed category"),
619            sealed_types.next().expect("a second sealed category"),
620        );
621        seal_records(&store, [2; 32], first, SEALED_STATE_PROBE_LIMIT + 6);
622        assert_eq!(
623            store.sealed_state(&RecordKey([1; 32])).expect("foreign"),
624            SealedState::Foreign
625        );
626
627        seal_records(&store, [1; 32], second, 1);
628        assert_eq!(
629            store.sealed_state(&RecordKey([1; 32])).expect("opens"),
630            SealedState::Opens
631        );
632    }
633
634    /// A damaged record key is its own answer. Nothing opens the sealed
635    /// records again, whoever sealed them, so it is not `Foreign`: a host
636    /// that refuses a foreign store must not refuse this one, where the
637    /// engine's regeneration is what tells the application what was lost.
638    #[test]
639    fn a_damaged_record_key_is_not_a_foreign_store() {
640        let root = TempRoot::new("state-damaged-key");
641        let store = open(&root);
642        assert_eq!(
643            store.sealed_state(&DamagedRecordKey).expect("empty"),
644            SealedState::Empty
645        );
646        let sealed_type = sealed_state_key_types().next().expect("a sealed category");
647        seal_records(&store, [1; 32], sealed_type, 1);
648        assert_eq!(
649            store.sealed_state(&DamagedRecordKey).expect("damaged"),
650            SealedState::KeyDamaged
651        );
652        assert_eq!(
653            store.sealed_state(&NoRecordKey).expect("no key"),
654            SealedState::Foreign
655        );
656    }
657
658    impl MlsStorage for NoRecordKey {
659        fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
660            Ok(())
661        }
662        fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
663            Ok(None)
664        }
665        fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
666            Ok(())
667        }
668        fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
669            Ok(Vec::new())
670        }
671    }
672
673    impl MlsStorage for UnreadableRecordKey {
674        fn store(&self, _: &str, _: &str, _: &[u8]) -> StorageResult<()> {
675            Ok(())
676        }
677        fn load(&self, _: &str, _: &str) -> StorageResult<Option<Vec<u8>>> {
678            Err(StorageError::LoadFailed(
679                "the disk did not answer".to_string(),
680            ))
681        }
682        fn delete(&self, _: &str, _: &str) -> StorageResult<()> {
683            Ok(())
684        }
685        fn list_keys(&self, _: &str) -> StorageResult<Vec<String>> {
686            Ok(Vec::new())
687        }
688    }
689
690    /// A read of the record key that failed is not "no record key": taking
691    /// it for one would refuse a healthy pair, and taking it for a usable
692    /// key is not possible. It is reported as what it is.
693    #[test]
694    fn a_record_key_that_cannot_be_read_is_not_an_answer() {
695        let root = TempRoot::new("state-unreadable-key");
696        let store = open(&root);
697        assert!(matches!(
698            store.sealed_state(&UnreadableRecordKey),
699            Err(FileStoreError::RecordKeyUnreadable(m)) if m.contains("did not answer")
700        ));
701    }
702
703    /// Only a record in a sealed category counts, only one whose framing
704    /// parses, and none is changed by being looked at.
705    #[test]
706    fn the_sealed_state_probe_reads_sealed_categories_and_changes_nothing() {
707        let root = TempRoot::new("state-probe");
708        let store = open(&root);
709        assert_eq!(
710            store.sealed_state(&NoRecordKey).expect("empty"),
711            SealedState::Empty
712        );
713
714        // Not a sealed category, so it says nothing about any key.
715        store
716            .store("lamport_clock", "current", b"1")
717            .expect("store");
718        assert_eq!(
719            store.sealed_state(&NoRecordKey).expect("unsealed only"),
720            SealedState::Empty
721        );
722
723        // A file in a sealed category whose framing does not parse.
724        let torn = store.type_directory("outbox").join("k_torn");
725        std::fs::create_dir_all(torn.parent().expect("parent")).expect("type directory");
726        std::fs::write(&torn, b"not a frame").expect("torn");
727        assert_eq!(
728            store.sealed_state(&NoRecordKey).expect("unparseable"),
729            SealedState::Empty
730        );
731
732        store
733            .store("outbox", "one", b"sealed elsewhere")
734            .expect("store");
735        let before = crate::file_store::test_support::files_under(root.path());
736        assert_eq!(
737            store.sealed_state(&NoRecordKey).expect("foreign"),
738            SealedState::Foreign
739        );
740        assert_eq!(
741            crate::file_store::test_support::files_under(root.path()),
742            before,
743            "the probe deletes nothing, the torn file included"
744        );
745        assert!(torn.exists());
746    }
747
748    #[test]
749    fn the_store_passes_the_conformance_suite() {
750        let root = TempRoot::new("state-conformance");
751        let report = crate::storage_conformance::run(&open(&root));
752        assert!(report.is_green(), "{report:#?}");
753    }
754
755    /// The vector `test_framing_golden_vector` pins for the Python provider,
756    /// which the Swift and Kotlin providers share. A Rust store that wrote
757    /// anything else would make a container unreadable across bindings.
758    #[test]
759    fn the_on_disk_format_matches_the_bindings_golden_vector() {
760        let framed = records::frame("outbox", "m-1", b"\xaa\xbb").expect("frame");
761        assert_eq!(framed, b"OPS1\x00\x06\x00\x03outboxm-1\xaa\xbb".to_vec());
762        assert_eq!(
763            type_directory_name("outbox"),
764            "t_d5fac01c82279b8b061df80b3c312942e2ce27a41a48b1b7479ff07ad5a6198d"
765        );
766        assert_eq!(
767            entry_name("outbox", "m-1"),
768            "k_db5fcc2398ef2863d4269a61be6ea2de1f80d2889f34670c9a57c79cbe8058a1"
769        );
770        assert_eq!(
771            records::parse_header(&framed).map(|h| h.value_offset),
772            Some(17)
773        );
774    }
775
776    /// A file laid down exactly as the Python provider writes it opens here.
777    #[test]
778    fn a_record_written_by_a_binding_opens() {
779        let root = TempRoot::new("state-foreign");
780        let store = open(&root);
781        let directory = store.directory().join(type_directory_name("outbox"));
782        std::fs::create_dir_all(&directory).expect("mkdir");
783        std::fs::write(
784            directory.join(entry_name("outbox", "m-1")),
785            b"OPS1\x00\x06\x00\x03outboxm-1\xaa\xbb",
786        )
787        .expect("write");
788
789        assert_eq!(
790            store.load("outbox", "m-1").expect("load"),
791            Some(vec![0xaa, 0xbb])
792        );
793        assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-1"]);
794    }
795
796    #[test]
797    fn records_survive_a_reopen() {
798        let root = TempRoot::new("state-reopen");
799        {
800            let store = open(&root);
801            store.store("outbox", "m-1", b"one").expect("store");
802            store.store("pending", "bob", b"").expect("store");
803        }
804        let store = open(&root);
805        assert_eq!(
806            store.load("outbox", "m-1").expect("load"),
807            Some(b"one".to_vec())
808        );
809        assert_eq!(
810            store.load("pending", "bob").expect("load"),
811            Some(Vec::new())
812        );
813        assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-1"]);
814    }
815
816    #[test]
817    fn an_oversized_file_is_dropped_without_being_read() {
818        let root = TempRoot::new("state-oversized");
819        let store = open(&root);
820        store.store("outbox", "m-1", b"x").expect("store");
821        let path = store.entry_path("outbox", "m-1");
822        let handle = std::fs::OpenOptions::new()
823            .write(true)
824            .open(&path)
825            .expect("open");
826        handle
827            .set_len((MAX_FRAME_BYTES + 1) as u64)
828            .expect("sparse extend");
829        drop(handle);
830
831        assert!(matches!(
832            store.load("outbox", "m-1"),
833            Err(ProtocolStateError::Corrupted(_))
834        ));
835        assert!(!path.exists());
836    }
837
838    #[test]
839    fn a_record_that_names_another_key_is_dropped_as_corrupt() {
840        let root = TempRoot::new("state-misnamed");
841        let store = open(&root);
842        store.store("outbox", "m-1", b"one").expect("store");
843        store.store("outbox", "m-2", b"two").expect("store");
844        let first = store.entry_path("outbox", "m-1");
845        std::fs::copy(store.entry_path("outbox", "m-2"), &first).expect("copy");
846
847        assert!(matches!(
848            store.load("outbox", "m-1"),
849            Err(ProtocolStateError::Corrupted(_))
850        ));
851        assert!(!first.exists());
852        // The planted copy never made m-2 appear twice, and m-2 still loads.
853        assert_eq!(store.list_keys("outbox").expect("list"), vec!["m-2"]);
854        assert_eq!(
855            store.load("outbox", "m-2").expect("load"),
856            Some(b"two".to_vec())
857        );
858    }
859
860    #[test]
861    fn garbage_framing_is_dropped_as_corrupt() {
862        let root = TempRoot::new("state-garbage");
863        let store = open(&root);
864        store.store("outbox", "m-1", b"one").expect("store");
865        let path = store.entry_path("outbox", "m-1");
866        std::fs::write(&path, (0u8..9).collect::<Vec<_>>()).expect("write");
867        assert!(matches!(
868            store.load("outbox", "m-1"),
869            Err(ProtocolStateError::Corrupted(_))
870        ));
871        assert!(!path.exists());
872    }
873
874    #[test]
875    fn a_value_over_the_ceiling_is_refused_on_store() {
876        let root = TempRoot::new("state-ceiling");
877        let store = open(&root);
878        let big = vec![0u8; MAX_VALUE_BYTES + 1];
879        assert!(matches!(
880            store.store("outbox", "m-1", &big),
881            Err(ProtocolStateError::StoreFailed(_))
882        ));
883        assert_eq!(store.load("outbox", "m-1").expect("load"), None);
884    }
885
886    /// Case-insensitive volumes fold names; a digest name never depends on
887    /// the key's case, so two ids differing only in case stay two records.
888    #[test]
889    fn ids_differing_only_in_case_are_distinct_records() {
890        let root = TempRoot::new("state-case");
891        let store = open(&root);
892        store.store("groups", "Team", b"upper").expect("store");
893        store.store("groups", "team", b"lower").expect("store");
894        assert_eq!(
895            store.load("groups", "Team").expect("load"),
896            Some(b"upper".to_vec())
897        );
898        assert_eq!(
899            store.load("groups", "team").expect("load"),
900            Some(b"lower".to_vec())
901        );
902        assert_eq!(
903            store.list_keys("groups").expect("list"),
904            vec!["Team", "team"]
905        );
906    }
907
908    #[test]
909    fn a_second_store_over_one_directory_is_refused() {
910        let root = TempRoot::new("state-in-use");
911        let first = open(&root);
912        assert!(matches!(
913            FileProtocolStateStorage::open(root.path(), &namespace()),
914            Err(FileStoreError::InUse(_))
915        ));
916        drop(first);
917        open(&root);
918    }
919
920    #[cfg(unix)]
921    #[test]
922    fn directories_and_records_are_owner_only() {
923        use std::os::unix::fs::PermissionsExt;
924        let root = TempRoot::new("state-mode");
925        let store = open(&root);
926        store.store("outbox", "m-1", b"x").expect("store");
927        let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
928        assert_eq!(mode(store.directory()), 0o700);
929        assert_eq!(mode(&store.type_directory("outbox")), 0o700);
930        assert_eq!(mode(&store.entry_path("outbox", "m-1")), 0o600);
931    }
932}