Skip to main content

offline_protocol/file_store/
pair.rs

1//! The two built-in stores opened as one pair, for one account.
2//!
3//! The invariant: an engine is never handed a protocol-state store that
4//! another identity wrote. The engine deletes a sealed record that does not
5//! open, as its restore reaches it, which is right for one damaged record and
6//! wrong for a store sealed under someone else's record key: the first
7//! restore deletes the queued and parked messages of an identity that is
8//! merely somewhere else.
9//!
10//! Ownership is recorded, not inferred. The first open of a pair writes one
11//! random pairing id into both stores, the protocol-state store first (see
12//! [`bind`] for why that order). A later open compares the two ids, and
13//! nothing the engine does to its record key changes them.
14//!
15//! The records alone ([`FileProtocolStateStorage::sealed_state`]) cannot
16//! tell another identity's state from this identity's state under a record
17//! key the engine had to regenerate: records sealed under the lost key
18//! outlive the launch that regenerated it (documents are read lazily, and
19//! each restore walk stops at its delete allowance), and the next launch
20//! finds records that do not open under a usable key. That reading refused
21//! the same identity over its own roots, so it decides only where the ids
22//! cannot: a store that holds no readable id, and ids that differ.
23//!
24//! Where the ids differ, only a sealed record that opens under this MLS
25//! store's record key admits the state, since only this identity's records
26//! open under it. That is the MLS store that lost its pairing file and was
27//! bound again beside another state root. The state root then takes the
28//! MLS store's id, so two state roots can carry one id; each still belongs
29//! to that identity.
30
31use std::path::{Component, Path, PathBuf};
32use std::sync::Arc;
33
34use rand_core::{OsRng, RngCore};
35
36use super::key::StoreKeyProvider;
37use super::{FileProtocolStateStorage, FileStoreError, SealedFileMlsStorage, SealedState};
38
39/// Length of the pairing id both stores of a pair hold.
40pub(super) const PAIRING_ID_BYTES: usize = 32;
41
42/// What the MLS store holds where its pairing id belongs.
43pub(super) enum StoredPairing {
44    /// Never bound.
45    Absent,
46    /// Bound, to this id.
47    Present([u8; PAIRING_ID_BYTES]),
48    /// A pairing file that does not open under the store key. It names no
49    /// state root, so the pair is judged by its records and bound afresh.
50    Damaged,
51}
52
53/// The MLS store and the protocol-state store of one account, opened
54/// together.
55///
56/// Use this rather than opening the two stores one by one: it refuses the
57/// pairs the engine would lose data over, which the stores cannot see alone.
58/// Hand [`Self::secure_storage`] and [`Self::state_storage`] to
59/// `initialize_mls`, and keep the pair to [`close`](Self::close) it.
60#[derive(Clone)]
61pub struct FileStorePair {
62    secure: Arc<SealedFileMlsStorage>,
63    state: Arc<FileProtocolStateStorage>,
64}
65
66impl std::fmt::Debug for FileStorePair {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("FileStorePair")
69            .field("secure", &self.secure)
70            .field("state", &self.state)
71            .finish()
72    }
73}
74
75impl FileStorePair {
76    /// Opens the MLS store at `mls_root` and the protocol-state store at
77    /// `state_root`, for the account `namespace`, refusing a pair the engine
78    /// would lose data over.
79    ///
80    /// Each refusal says what to change, and none changes or deletes a
81    /// record:
82    ///
83    /// - [`FileStoreError::RootsOverlap`]: one directory for both, or one
84    ///   inside the other. Asked as spelled before anything is created, and
85    ///   again of the directories once both exist, because a volume that
86    ///   folds case makes one directory of two spellings.
87    /// - [`FileStoreError::StateWithoutIdentity`]: protocol state with no MLS
88    ///   record beside it. A new identity would be minted, and it could not
89    ///   unseal that state. Asked before anything is created.
90    /// - [`FileStoreError::ForeignState`]: protocol state that belongs to
91    ///   another MLS store. Asked once both are open.
92    /// - Whatever either store's own open refuses: a wrong key, a directory
93    ///   another store holds.
94    ///
95    /// A record key the MLS store reports as damaged is not refused: nothing
96    /// opens the state sealed under it again, and the engine's new key and
97    /// its settlement of that state as failed is how the application learns
98    /// which messages were lost. Once the pair is bound (this binds it), the
99    /// pairing ids decide every later open, so the records left under the
100    /// lost key do not make it look like another identity's state. A pair
101    /// whose record key was regenerated while its stores were opened one by
102    /// one, never bound, is still judged by those records on its first open
103    /// here, and can be refused.
104    ///
105    /// A pair that passes is bound: both stores hold the same pairing id
106    /// from then on.
107    pub fn open(
108        mls_root: impl AsRef<Path>,
109        state_root: impl AsRef<Path>,
110        namespace: &str,
111        provider: &dyn StoreKeyProvider,
112    ) -> Result<Self, FileStoreError> {
113        let (mls_root, state_root) = (mls_root.as_ref(), state_root.as_ref());
114        let overlap = |on_this_volume| FileStoreError::RootsOverlap {
115            mls_root: mls_root.to_path_buf(),
116            state_root: state_root.to_path_buf(),
117            on_this_volume,
118        };
119        if roots_overlap(mls_root, state_root) {
120            return Err(overlap(false));
121        }
122        refuse_state_without_identity(mls_root, state_root, namespace)?;
123
124        // MLS first: it proves the key, and a refused key should leave no
125        // protocol-state directory behind for an operator to wonder about.
126        let secure = SealedFileMlsStorage::open(mls_root, namespace, provider)?;
127        let state = FileProtocolStateStorage::open(state_root, namespace)?;
128
129        // Both roots exist now, so the question asked above by spelling can
130        // be asked of the directories themselves. APFS and NTFS fold case by
131        // default, and no comparison of names that do not exist yet can know.
132        if roots_overlap(mls_root, state_root) {
133            return Err(overlap(true));
134        }
135
136        let pairing = secure.pairing()?;
137        // Asked under both locks, so nothing writes in between. A state store
138        // that holds no record has nothing to lose to any identity.
139        if FileProtocolStateStorage::holds_records(state_root, namespace)? {
140            admit(
141                ownership(&secure, &state, &pairing)?,
142                state.directory(),
143                mls_root,
144            )?;
145        }
146        bind(&secure, &state, pairing)?;
147        Ok(Self {
148            secure: Arc::new(secure),
149            state: Arc::new(state),
150        })
151    }
152
153    /// The MLS store, for `initialize_mls`.
154    pub fn secure_storage(&self) -> Arc<SealedFileMlsStorage> {
155        self.secure.clone()
156    }
157
158    /// The protocol-state store, for `initialize_mls`.
159    pub fn state_storage(&self) -> Arc<FileProtocolStateStorage> {
160        self.state.clone()
161    }
162
163    /// Releases both directories, whoever still holds either store. The
164    /// protocol-state store first: it is the one sealed under the other's
165    /// record key. See [`SealedFileMlsStorage::close`].
166    pub fn close(&self) {
167        self.state.close();
168        self.secure.close();
169    }
170}
171
172/// Whether the state store belongs with the MLS store.
173///
174/// Decided by the pairing ids when both stores hold one: equal ids belong
175/// together. A state store bound to another id belongs with another MLS
176/// store unless one of its sealed records opens under this MLS store's
177/// record key, which only this identity's records do: an MLS store that lost
178/// its pairing file and was bound again beside another state root still
179/// owns the state root it started with. When the MLS store holds no readable id (never bound, restored from
180/// a backup older than its binding, the file removed or damaged), or the
181/// state store holds none, the sealed records decide: another identity's
182/// records still open under no key this store holds.
183///
184/// The binding says which MLS store the state root was paired with, not who
185/// wrote to it last. A state root shared with another writer (the keyring
186/// mode of a binding over the same directory, a Rust host opening the state
187/// store alone) can hold records another identity sealed while the ids still
188/// match. That sharing already loses data without this: each identity's
189/// restore deletes what the other sealed.
190fn ownership(
191    secure: &SealedFileMlsStorage,
192    state: &FileProtocolStateStorage,
193    pairing: &StoredPairing,
194) -> Result<SealedState, FileStoreError> {
195    match (state.pairing()?, pairing) {
196        (Some(bound), StoredPairing::Present(ours)) if bound == *ours => Ok(SealedState::Opens),
197        // A record that opens is proof no id can overrule. Anything short of
198        // it stays another MLS store's: records that do not open, a damaged
199        // record key, and unsealed records alone, which prove nothing.
200        (Some(_), StoredPairing::Present(_)) => Ok(match state.sealed_state(secure)? {
201            SealedState::Opens => SealedState::Opens,
202            SealedState::Empty | SealedState::Foreign | SealedState::KeyDamaged => {
203                SealedState::Foreign
204            }
205        }),
206        (Some(_), StoredPairing::Absent | StoredPairing::Damaged) | (None, _) => {
207            state.sealed_state(secure)
208        }
209    }
210}
211
212/// Refuses protocol state another identity wrote, and nothing else.
213///
214/// Exhaustive on purpose: a verdict added later has to be decided here, not
215/// let through by a wildcard.
216fn admit(
217    verdict: SealedState,
218    state_directory: &Path,
219    mls_root: &Path,
220) -> Result<(), FileStoreError> {
221    match verdict {
222        SealedState::Empty | SealedState::Opens => Ok(()),
223        SealedState::KeyDamaged => {
224            tracing::warn!(
225                state = %state_directory.display(),
226                "the protocol-state record key is damaged: the sealed state beside it \
227                 cannot be opened again, and the engine will settle it as failed"
228            );
229            Ok(())
230        }
231        SealedState::Foreign => Err(FileStoreError::ForeignState {
232            state: state_directory.to_path_buf(),
233            mls_root: mls_root.to_path_buf(),
234        }),
235    }
236}
237
238/// Makes both stores hold one pairing id: the MLS store's when it has one,
239/// otherwise a new one.
240///
241/// A new id goes to the state store first. A crash (or a failed write)
242/// between the two writes then leaves the MLS store without a readable id,
243/// which the next open judges by the records and binds again. The other
244/// order could leave the MLS store on the new id and the state store on an
245/// old one it was rebound from, which reads as another MLS store's state on
246/// every open after.
247fn bind(
248    secure: &SealedFileMlsStorage,
249    state: &FileProtocolStateStorage,
250    pairing: StoredPairing,
251) -> Result<(), FileStoreError> {
252    match pairing {
253        StoredPairing::Present(id) => {
254            if state.pairing()? != Some(id) {
255                state.set_pairing(&id)?;
256            }
257        }
258        StoredPairing::Absent | StoredPairing::Damaged => {
259            let mut id = [0u8; PAIRING_ID_BYTES];
260            OsRng.fill_bytes(&mut id);
261            state.set_pairing(&id)?;
262            secure.set_pairing(&id)?;
263        }
264    }
265    Ok(())
266}
267
268/// Refuses a protocol-state account directory that holds records when the
269/// MLS account directory holds none.
270///
271/// That shape is a deployment moving onto the file stores (or one whose
272/// `mls_root` was lost) while keeping its state root. The MLS store would
273/// mint a new identity, so the device gets a new address, and the new
274/// identity's record key cannot unseal the old records: the first restore
275/// deletes the parked messages it cannot read.
276///
277/// The MLS side is asked for record files, not for its directory, a type
278/// directory or its pairing file: an attempt refused at the state root has
279/// already created the MLS directory, its lock and its key check, a first
280/// write that failed leaves an empty type directory, and the pairing file is
281/// written before the engine mints the identity. An existence test would let
282/// the retry through. A directory that cannot be read on either side is
283/// refused too, never taken for empty. Nothing is created before this check.
284fn refuse_state_without_identity(
285    mls_root: &Path,
286    state_root: &Path,
287    namespace: &str,
288) -> Result<(), FileStoreError> {
289    if SealedFileMlsStorage::holds_records(mls_root, namespace)? {
290        return Ok(());
291    }
292    if FileProtocolStateStorage::holds_records(state_root, namespace)? {
293        return Err(FileStoreError::StateWithoutIdentity {
294            state: state_root.join(namespace),
295            mls_root: mls_root.to_path_buf(),
296        });
297    }
298    Ok(())
299}
300
301/// Whether one root is the other, or lies inside it.
302///
303/// Neither root need exist yet, and either may be relative or spelled with
304/// `.` and `..`, so each is resolved first: see [`resolved_root`].
305fn roots_overlap(a: &Path, b: &Path) -> bool {
306    let (a, b) = (resolved_root(a), resolved_root(b));
307    a.starts_with(&b) || b.starts_with(&a)
308}
309
310/// `path` made absolute, with its longest existing ancestor canonicalized
311/// (so a symlink or a differently spelled existing prefix compares equal)
312/// and the rest, which does not exist and so holds no symlink, resolved by
313/// its components.
314fn resolved_root(path: &Path) -> PathBuf {
315    let absolute = if path.is_absolute() {
316        path.to_path_buf()
317    } else {
318        std::env::current_dir()
319            .map(|cwd| cwd.join(path))
320            .unwrap_or_else(|_| path.to_path_buf())
321    };
322    let mut existing = absolute.as_path();
323    let mut rest = Vec::new();
324    let base = loop {
325        if let Ok(canonical) = existing.canonicalize() {
326            break canonical;
327        }
328        match (existing.parent(), existing.file_name()) {
329            (Some(parent), Some(name)) => {
330                rest.push(Component::Normal(name));
331                existing = parent;
332            }
333            // `..` or `.` as the last component: keep it for the walk below.
334            (Some(parent), None) => {
335                rest.extend(existing.components().next_back());
336                existing = parent;
337            }
338            (None, _) => break PathBuf::new(),
339        }
340    };
341    let mut resolved = base;
342    for component in rest.into_iter().rev() {
343        match component {
344            Component::ParentDir => {
345                resolved.pop();
346            }
347            Component::CurDir => {}
348            other => resolved.push(other),
349        }
350    }
351    resolved
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::file_store::account_storage_namespace;
358    use crate::file_store::mls::PAIRING_FILE;
359    use crate::file_store::test_support::{files_under, TempRoot};
360    use crate::file_store::StaticStoreKey;
361    use crate::protocol_state_storage::ProtocolStateStorage;
362    use offline_protocol_mls::storage::MlsStorage;
363
364    fn namespace() -> String {
365        account_storage_namespace("com.example.pair", "alice")
366    }
367
368    struct Roots {
369        keys: TempRoot,
370        state: TempRoot,
371    }
372
373    fn roots(label: &str) -> Roots {
374        Roots {
375            keys: TempRoot::new(&format!("{label}-keys")),
376            state: TempRoot::new(&format!("{label}-state")),
377        }
378    }
379
380    fn open(roots: &Roots) -> Result<FileStorePair, FileStoreError> {
381        FileStorePair::open(
382            roots.keys.path(),
383            roots.state.path(),
384            &namespace(),
385            &StaticStoreKey::new([4; 32]),
386        )
387    }
388
389    /// Only another identity's state is refused, and the match that says so
390    /// is exhaustive: a damaged record key is let through, since nothing
391    /// opens that state again and the engine's regeneration is what settles
392    /// the messages lost with it.
393    #[test]
394    fn only_state_another_identity_wrote_is_refused() {
395        let (state, keys) = (Path::new("/srv/state/account"), Path::new("/srv/keys"));
396        let err = admit(SealedState::Foreign, state, keys).expect_err("foreign");
397        let message = err.to_string();
398        assert!(
399            matches!(err, FileStoreError::ForeignState { .. })
400                && message.contains("another identity")
401                && message.contains("fresh directory")
402                && message.contains("/srv/state/account")
403                && message.contains("/srv/keys"),
404            "{message}"
405        );
406        for verdict in [
407            SealedState::Empty,
408            SealedState::Opens,
409            SealedState::KeyDamaged,
410        ] {
411            admit(verdict, state, keys).unwrap_or_else(|err| panic!("{verdict:?}: {err}"));
412        }
413    }
414
415    /// The first open binds both stores to one id, the state store first, and
416    /// neither pairing file is a record: the probes that decide whether a new
417    /// identity may start still see an empty pair.
418    #[test]
419    fn the_first_open_binds_the_pair_and_writes_no_record() {
420        let roots = roots("pair-bind");
421        let pair = open(&roots).expect("open");
422        let StoredPairing::Present(id) = pair.secure.pairing().expect("mls pairing") else {
423            panic!("the MLS store is bound");
424        };
425        assert_eq!(pair.state.pairing().expect("state pairing"), Some(id));
426        assert!(!SealedFileMlsStorage::holds_records(roots.keys.path(), &namespace()).unwrap());
427        assert!(
428            !FileProtocolStateStorage::holds_records(roots.state.path(), &namespace()).unwrap()
429        );
430
431        pair.close();
432        let again = open(&roots).expect("reopen");
433        assert!(
434            matches!(again.secure.pairing().unwrap(), StoredPairing::Present(same) if same == id),
435            "a bound pair keeps its id"
436        );
437    }
438
439    /// A state store bound to one MLS store is refused beside another, and
440    /// the refusal writes nothing. An MLS store that holds no pairing id at
441    /// all did not write it either.
442    #[test]
443    fn state_bound_to_another_mls_store_is_refused_and_kept() {
444        let ours = roots("pair-ours");
445        let pair = open(&ours).expect("open");
446        pair.secure
447            .store("identity", "self", b"ours")
448            .expect("an identity");
449        pair.state
450            .store("lamport_clock", "current", b"7")
451            .expect("a record");
452        pair.close();
453
454        let theirs = roots("pair-theirs");
455        let other = open(&theirs).expect("another identity's pair");
456        other
457            .secure
458            .store("identity", "self", b"theirs")
459            .expect("another identity");
460        other.close();
461        let before = files_under(ours.state.path());
462        let err = FileStorePair::open(
463            theirs.keys.path(),
464            ours.state.path(),
465            &namespace(),
466            &StaticStoreKey::new([4; 32]),
467        )
468        .expect_err("another MLS store");
469        assert!(matches!(err, FileStoreError::ForeignState { .. }), "{err}");
470        assert_eq!(files_under(ours.state.path()), before);
471
472        // An MLS store with an identity and no pairing id (never bound, or
473        // restored from a backup older than its binding) is judged by the
474        // records: sealed records it holds no key for are another identity's.
475        let sealed = crate::protocol::state_crypto::StateRecordCipher::new(&[9; 32])
476            .seal("outbox", "parked", b"another identity's")
477            .expect("seal");
478        let bound_state = FileProtocolStateStorage::open(ours.state.path(), &namespace())
479            .expect("open the state store");
480        bound_state
481            .store("outbox", "parked", &sealed)
482            .expect("a sealed record");
483        bound_state.close();
484        let before = files_under(ours.state.path());
485        let unbound = TempRoot::new("pair-unbound-keys");
486        let secure =
487            SealedFileMlsStorage::open(unbound.path(), &namespace(), &StaticStoreKey::new([4; 32]))
488                .expect("open");
489        secure
490            .store("identity", "self", b"key")
491            .expect("an identity");
492        secure.close();
493        let err = FileStorePair::open(
494            unbound.path(),
495            ours.state.path(),
496            &namespace(),
497            &StaticStoreKey::new([4; 32]),
498        )
499        .expect_err("records that open under no key it holds");
500        assert!(matches!(err, FileStoreError::ForeignState { .. }), "{err}");
501        assert_eq!(files_under(ours.state.path()), before);
502
503        open(&ours).expect("the pair that belongs together still opens");
504    }
505
506    /// A state store with no record has nothing to lose, so it is not
507    /// refused whatever id it carries: the pair is bound afresh.
508    #[test]
509    fn a_state_store_with_no_record_is_rebound_not_refused() {
510        let ours = roots("pair-empty-ours");
511        drop(open(&ours).expect("open"));
512        let theirs = roots("pair-empty-theirs");
513        let pair = FileStorePair::open(
514            theirs.keys.path(),
515            ours.state.path(),
516            &namespace(),
517            &StaticStoreKey::new([4; 32]),
518        )
519        .expect("no record to lose");
520        let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
521            panic!("bound");
522        };
523        assert_eq!(pair.state.pairing().unwrap(), Some(id));
524    }
525
526    /// The same identity with its MLS pairing file gone (a backup older than
527    /// the binding, a copy that left it behind) is judged by its records, and
528    /// its own records do not make it foreign.
529    #[test]
530    fn a_missing_mls_pairing_is_judged_by_the_records_not_refused() {
531        let roots = roots("pair-missing");
532        let pair = open(&roots).expect("open");
533        pair.secure
534            .store("identity", "self", b"ours")
535            .expect("an identity");
536        pair.state
537            .store("lamport_clock", "current", b"7")
538            .expect("a record");
539        let path = pair.secure.directory().join(PAIRING_FILE);
540        pair.close();
541        std::fs::remove_file(&path).expect("remove the pairing file");
542
543        let pair = open(&roots).expect("the same identity over its own state");
544        let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
545            panic!("bound again");
546        };
547        assert_eq!(pair.state.pairing().unwrap(), Some(id));
548    }
549
550    /// A rebind that fails after its first write (a crash, a full disk) must
551    /// leave a pair the next open still admits. The state store is written
552    /// first, so when its write fails the MLS store keeps no readable id and
553    /// the records decide again. The other order left the MLS store on the
554    /// new id and the state store on the old one, refused for good.
555    #[test]
556    fn a_rebind_that_fails_part_way_still_opens_next_time() {
557        let roots = roots("pair-cut-short");
558        let pair = open(&roots).expect("open");
559        pair.secure
560            .store("identity", "self", b"ours")
561            .expect("an identity");
562        pair.state
563            .store("lamport_clock", "current", b"7")
564            .expect("a record");
565        let path = pair.secure.directory().join(PAIRING_FILE);
566        pair.close();
567        std::fs::remove_file(&path).expect("unbind the MLS store");
568
569        crate::file_store::state::tests::FAIL_PAIRING_WRITE.with(|fail| fail.set(true));
570        let err = open(&roots).expect_err("the state store's pairing write fails");
571        assert!(matches!(err, FileStoreError::Io { .. }), "{err}");
572
573        open(&roots).expect("the next open admits the pair and binds it");
574    }
575
576    /// Gives the MLS store a record key and seals one record under it in the
577    /// state store, as the engine does.
578    fn seal_one_record(pair: &FileStorePair, key: [u8; 32]) {
579        use crate::protocol::storage_keys::{STATE_RECORD_KEY, STATE_RECORD_KEY_ID};
580        pair.secure
581            .store(STATE_RECORD_KEY, STATE_RECORD_KEY_ID, &key)
582            .expect("a record key");
583        let key_type = crate::protocol::sealed_state_key_types()
584            .next()
585            .expect("a sealed category");
586        let sealed = crate::protocol::state_crypto::StateRecordCipher::new(&key)
587            .seal(key_type, "parked", b"ours")
588            .expect("seal");
589        pair.state
590            .store(key_type, "parked", &sealed)
591            .expect("a sealed record");
592    }
593
594    /// An id mismatch is not the last word when a record opens. The MLS
595    /// store lost its pairing file and was bound again beside a fresh state
596    /// root; its first state root, put back, holds records only this
597    /// identity's record key opens, and it is admitted and bound again.
598    #[test]
599    fn a_state_root_whose_records_open_is_admitted_over_an_id_mismatch() {
600        let ours = roots("pair-mismatch-opens");
601        let pair = open(&ours).expect("open");
602        pair.secure
603            .store("identity", "self", b"ours")
604            .expect("an identity");
605        seal_one_record(&pair, [5; 32]);
606        let pairing_file = pair.secure.directory().join(PAIRING_FILE);
607        pair.close();
608        std::fs::remove_file(&pairing_file).expect("lose the MLS pairing file");
609
610        let elsewhere = TempRoot::new("pair-mismatch-elsewhere");
611        let rebound = FileStorePair::open(
612            ours.keys.path(),
613            elsewhere.path(),
614            &namespace(),
615            &StaticStoreKey::new([4; 32]),
616        )
617        .expect("bound beside a fresh state root");
618        let StoredPairing::Present(new_id) = rebound.secure.pairing().unwrap() else {
619            panic!("bound again");
620        };
621        rebound.close();
622        assert_ne!(
623            FileProtocolStateStorage::open(ours.state.path(), &namespace())
624                .and_then(|state| {
625                    let id = state.pairing();
626                    state.close();
627                    id
628                })
629                .expect("the first state root"),
630            Some(new_id),
631            "the first state root still carries the old id"
632        );
633
634        let pair = open(&ours).expect("its own records open under its record key");
635        assert_eq!(pair.state.pairing().unwrap(), Some(new_id), "bound again");
636    }
637
638    /// A state store with no record is admitted beside an MLS store bound to
639    /// another id, and takes that id: the ids alone would call it another
640    /// MLS store's, and it has nothing to lose to anyone.
641    #[test]
642    fn a_state_store_with_no_record_is_rebound_beside_another_bound_mls_store() {
643        let theirs = roots("pair-empty-bound-theirs");
644        open(&theirs).expect("their pair").close();
645        let ours = roots("pair-empty-bound-ours");
646        open(&ours).expect("our pair").close();
647
648        let pair = FileStorePair::open(
649            theirs.keys.path(),
650            ours.state.path(),
651            &namespace(),
652            &StaticStoreKey::new([4; 32]),
653        )
654        .expect("no record to lose");
655        let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
656            panic!("bound");
657        };
658        assert_eq!(pair.state.pairing().unwrap(), Some(id));
659    }
660
661    /// A reinstall: the state root is gone and made again beside an MLS root
662    /// that is still bound. The new state store takes the MLS store's id, or
663    /// every later open would judge the pair by its records alone.
664    #[test]
665    fn a_state_root_made_again_takes_the_bound_mls_id() {
666        let roots = roots("pair-reinstall");
667        let pair = open(&roots).expect("open");
668        pair.secure
669            .store("identity", "self", b"ours")
670            .expect("an identity");
671        let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
672            panic!("bound");
673        };
674        pair.close();
675        std::fs::remove_dir_all(roots.state.path()).expect("remove the state root");
676
677        let pair = open(&roots).expect("reopen over a new state root");
678        assert_eq!(pair.state.pairing().unwrap(), Some(id));
679    }
680
681    /// A pairing file that no longer opens names no state store. The pair
682    /// is judged by its records, and bound again once it passes.
683    #[test]
684    fn a_damaged_mls_pairing_falls_back_to_the_records() {
685        let roots = roots("pair-damaged");
686        let pair = open(&roots).expect("open");
687        let path = pair.secure.directory().join(PAIRING_FILE);
688        pair.secure
689            .store("identity", "self", b"ours")
690            .expect("an identity");
691        pair.state
692            .store("lamport_clock", "current", b"7")
693            .expect("an unsealed record");
694        pair.close();
695        let mut bytes = std::fs::read(&path).expect("pairing file");
696        let last = bytes.len() - 1;
697        bytes[last] ^= 1;
698        std::fs::write(&path, bytes).expect("damage");
699
700        let pair = open(&roots).expect("no sealed record, so the records say Empty");
701        let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
702            panic!("bound again");
703        };
704        assert_eq!(pair.state.pairing().unwrap(), Some(id));
705    }
706}