Skip to main content

offline_protocol_mls/
storage_conformance.rs

1//! A portable conformance suite for [`MlsStorage`] implementations.
2//!
3//! Every binding hands the SDK its own `MlsStorage`: the Keychain on iOS, an
4//! encrypted preferences file on Android, the platform credential store in
5//! Python, and whatever a Rust host wires in. Each is a promise that bytes
6//! stored under `(key_type, key_id)` come back whole, that categories do not
7//! bleed into each other, and that an overwrite replaces. Nothing checks the
8//! promise: an implementation that returns `Ok` from every method can still
9//! drop an overwrite, split a key id on the `:` a session id carries, or
10//! truncate a ratchet tree, and the symptom arrives much later as a group
11//! that cannot decrypt.
12//!
13//! Green here is the definition of "this backend honours the trait
14//! contract". It is not a persistence test: every check runs against one
15//! open instance, so a backend that forgets everything on reopen, or that
16//! acknowledges a write it has not made durable, can still be green. A
17//! persistent backend proves those with a reopen test of its own.
18//!
19//! The suite writes only under its own probe key types and deletes only
20//! what it wrote, provided the backend keeps key types apart. That is why
21//! key-type isolation is checked first, with point writes and point deletes
22//! of ids nothing else uses, and why a failure there ends the run before
23//! any listed delete: on a backend that merges categories, listing a probe
24//! key type names the identity too. One check cannot be made safe in
25//! advance. `clear_type` has to be called to be tested, and a backend that
26//! clears every category takes the real records with it. Probe a new
27//! backend on a scratch instance until it is green; after that it is safe
28//! to run against a live store.
29//!
30//! The report type is shared with the protocol-state suite in the engine
31//! crate, which re-exports it, so the two suites render one JSON shape:
32//! `{"passed":[...],"failures":[{"check","detail"}]}`.
33
34use serde::{Deserialize, Serialize};
35
36use crate::storage::{MlsStorage, StorageError};
37use crate::GroupId;
38
39/// The key type every probe record is written under.
40///
41/// Deliberately not a real category: nothing else reads it, and a leftover
42/// record from an interrupted run is inert.
43pub const CONFORMANCE_KEY_TYPE: &str = "mls_storage_conformance_probe";
44
45/// A second key type, used to prove categories do not bleed into each other.
46pub const CONFORMANCE_KEY_TYPE_OTHER: &str = "mls_storage_conformance_probe_other";
47
48/// A key type the suite never writes, used to prove listing and clearing an
49/// untouched category is empty and not an error.
50const CONFORMANCE_KEY_TYPE_UNUSED: &str = "mls_storage_conformance_probe_unused";
51
52/// A key type that ends with [`CONFORMANCE_KEY_TYPE`], the way the SDK's
53/// own key types are related: `key_package` ends `contact_key_package` and
54/// `openmls_key_package`, and `group_state` ends `openmls_group_state`. The
55/// two probe types above are related by prefix only, so a backend that lists
56/// by suffix passed every check and then listed every OpenMLS record as a
57/// group. The isolation check writes its listing probe here too.
58const CONFORMANCE_KEY_TYPE_SUFFIXED: &str = "suffixed_mls_storage_conformance_probe";
59
60/// The id the isolation check writes under both probe key types. Nothing
61/// else uses it, so a point write or delete of it cannot touch a real
62/// record even on a backend that ignores the key type.
63const ISOLATION_PROBE_ID: &str = "mls_storage_conformance_isolation";
64
65/// The id the isolation check writes under the second probe key type only,
66/// to catch a listing that crosses key types.
67const ISOLATION_LISTING_PROBE_ID: &str = "mls_storage_conformance_isolation_listing";
68
69/// The largest value the suite writes.
70///
71/// The largest record the SDK stores through this trait is a group's ratchet
72/// tree, which grows by about 1.1 KiB per member: 74 KiB at 65 members and
73/// 270 KiB at the default `max_group_members` of 256 (measured on the
74/// serialized OpenMLS tree). Half a MiB covers the default with headroom. A
75/// deployment that raises the member cap scales the record linearly and
76/// should probe its backend at its own ceiling.
77pub const LARGE_RECORD_BYTES: usize = 512 * 1024;
78
79/// Writers in the concurrency check.
80const CONCURRENT_WRITERS: usize = 8;
81/// Overwrites each writer performs.
82const WRITES_PER_WRITER: usize = 16;
83/// Size of each value under concurrent overwrite. Large enough that a
84/// backend copying without a lock has a window to be caught in.
85const CONCURRENT_VALUE_BYTES: usize = 4096;
86
87/// One failed check.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ConformanceFailure {
90    /// The check that failed.
91    pub check: String,
92    /// What went wrong, in enough detail to fix the adapter.
93    pub detail: String,
94}
95
96/// The result of running a suite against one backend.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct ConformanceReport {
99    /// Checks that passed, in run order.
100    pub passed: Vec<String>,
101    /// Checks that failed.
102    pub failures: Vec<ConformanceFailure>,
103}
104
105impl ConformanceReport {
106    /// An empty report.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Whether the backend is supported.
112    pub fn is_green(&self) -> bool {
113        self.failures.is_empty()
114    }
115
116    /// A one-line summary suitable for a test assertion message.
117    pub fn summary(&self) -> String {
118        if self.is_green() {
119            format!("{} checks passed", self.passed.len())
120        } else {
121            let names: Vec<&str> = self
122                .failures
123                .iter()
124                .map(|failure| failure.check.as_str())
125                .collect();
126            format!(
127                "{} of {} checks failed: {}",
128                self.failures.len(),
129                self.passed.len() + self.failures.len(),
130                names.join(", ")
131            )
132        }
133    }
134
135    /// Records a passed check.
136    pub fn pass(&mut self, check: &str) {
137        self.passed.push(check.to_string());
138    }
139
140    /// Records a failed check.
141    pub fn fail(&mut self, check: &str, detail: impl Into<String>) {
142        self.failures.push(ConformanceFailure {
143            check: check.to_string(),
144            detail: detail.into(),
145        });
146    }
147
148    /// Records one check's outcome.
149    pub fn check(&mut self, name: &str, outcome: Result<(), String>) {
150        match outcome {
151            Ok(()) => self.pass(name),
152            Err(detail) => self.fail(name, detail),
153        }
154    }
155}
156
157fn expect_load(
158    storage: &dyn MlsStorage,
159    key_type: &str,
160    key_id: &str,
161    expected: &[u8],
162) -> Result<(), String> {
163    match storage.load(key_type, key_id) {
164        Ok(Some(actual)) if actual == expected => Ok(()),
165        Ok(Some(actual)) => Err(format!(
166            "loaded {} bytes, expected {} (contents differ)",
167            actual.len(),
168            expected.len()
169        )),
170        Ok(None) => Err("stored value loaded back as missing".to_string()),
171        Err(err) => Err(format!("load failed: {err}")),
172    }
173}
174
175fn store_and_read_back(
176    storage: &dyn MlsStorage,
177    key_id: &str,
178    value: &[u8],
179    what: &str,
180) -> Result<(), String> {
181    storage
182        .store(CONFORMANCE_KEY_TYPE, key_id, value)
183        .map_err(|err| format!("store of {what} failed: {err}"))?;
184    expect_load(storage, CONFORMANCE_KEY_TYPE, key_id, value)
185        .map_err(|detail| format!("{what}: {detail}"))
186}
187
188/// Run the suite.
189///
190/// Never panics on its own account: an adapter that misbehaves produces
191/// failures in the report, because the whole point is to hand an adapter
192/// author a list of what to fix rather than a stack trace.
193pub fn run(storage: &dyn MlsStorage) -> ConformanceReport {
194    let mut report = ConformanceReport::new();
195
196    // 1. Key types are separate namespaces, checked before anything is
197    // deleted. A group's id keys its state, its metadata and its OpenMLS
198    // records in different categories; a backend that merges them serves
199    // one where another was asked for. It is also the one defect that turns
200    // this suite destructive: every listed delete below trusts that listing
201    // a probe key type names only probe records, and on a backend that
202    // merges categories it names the identity as well. So this check uses
203    // point writes and point deletes of ids nothing else uses, and a
204    // failure ends the run here.
205    let isolation = key_types_are_isolated(storage);
206    let isolated = isolation.is_ok();
207    report.check("key_types_are_separate_namespaces", isolation);
208    if !isolated {
209        return report;
210    }
211
212    // Anything left from an interrupted earlier run would make the listing
213    // checks report failures that are not the adapter's fault.
214    cleanup(storage);
215
216    // 2. The basic contract.
217    report.check(
218        "store_then_load",
219        store_and_read_back(storage, "basic", b"hello", "a value"),
220    );
221
222    // 3. Values are bytes, not text. Every record here is either a key or
223    // ciphertext; a backend that round-trips through a string type mangles
224    // both, and the symptom is a group that will not open.
225    let binary: Vec<u8> = (0u8..=255).collect();
226    report.check(
227        "binary_values_round_trip",
228        store_and_read_back(storage, "binary", &binary, "a binary value"),
229    );
230
231    // 4. An empty value is a value, and must not read back as absent.
232    report.check(
233        "empty_value_is_not_missing",
234        store_and_read_back(storage, "empty", b"", "an empty value"),
235    );
236
237    // 5. A second store replaces; it does not append or refuse. Every
238    // epoch rewrites a group's secrets under the same id, so a backend
239    // that keeps the first write serves last epoch's keys forever.
240    let outcome = storage
241        .store(CONFORMANCE_KEY_TYPE, "overwrite", b"first")
242        .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE, "overwrite", b"second"))
243        .map_err(|err| format!("store failed: {err}"))
244        .and_then(|()| expect_load(storage, CONFORMANCE_KEY_TYPE, "overwrite", b"second"));
245    report.check("store_overwrites", outcome);
246
247    // 6. A key that was never written reads as missing, not as an error.
248    // The manager probes for a session before creating one; an error here
249    // is a failure to send, not "no session yet".
250    let outcome = match storage.load(CONFORMANCE_KEY_TYPE, "never_written") {
251        Ok(None) => Ok(()),
252        Ok(Some(_)) => Err("a key that was never written returned a value".to_string()),
253        Err(StorageError::KeyNotFound(_)) => {
254            Err("absent keys must load as Ok(None), not KeyNotFound".to_string())
255        }
256        Err(err) => Err(format!("load of an absent key failed: {err}")),
257    };
258    report.check("absent_key_loads_as_none", outcome);
259
260    // 7. Delete removes, and deleting again is not an error. A consumed key
261    // package is deleted on the path that spent it; a second delete after a
262    // crash must not turn a clean recovery into a failure.
263    let outcome = storage
264        .store(CONFORMANCE_KEY_TYPE, "deletable", b"x")
265        .and_then(|()| storage.delete(CONFORMANCE_KEY_TYPE, "deletable"))
266        .map_err(|err| format!("store/delete failed: {err}"))
267        .and_then(|()| match storage.load(CONFORMANCE_KEY_TYPE, "deletable") {
268            Ok(None) => Ok(()),
269            Ok(Some(_)) => Err("value survived a delete".to_string()),
270            Err(err) => Err(format!("load after delete failed: {err}")),
271        })
272        .and_then(
273            |()| match storage.delete(CONFORMANCE_KEY_TYPE, "deletable") {
274                Ok(()) => Ok(()),
275                Err(err) => Err(format!("second delete must succeed, got: {err}")),
276            },
277        );
278    report.check("delete_removes_and_is_idempotent", outcome);
279
280    // 8. Listing names what is there, and only within its own key type.
281    // Listing is how the manager finds every group on restart and every
282    // key package to purge; an omitted id is a group that vanishes.
283    let outcome = (|| -> Result<(), String> {
284        storage
285            .store(CONFORMANCE_KEY_TYPE, "shared_id", b"mine")
286            .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE_OTHER, "shared_id", b"theirs"))
287            .map_err(|err| format!("store failed: {err}"))?;
288        let keys = storage
289            .list_keys(CONFORMANCE_KEY_TYPE)
290            .map_err(|err| format!("list_keys failed: {err}"))?;
291        for expected in ["basic", "binary", "empty", "overwrite", "shared_id"] {
292            if !keys.iter().any(|key| key == expected) {
293                return Err(format!("list_keys omitted {expected:?}"));
294            }
295        }
296        if keys.iter().any(|key| key == "deletable") {
297            return Err("list_keys named a deleted record".to_string());
298        }
299        let other = storage
300            .list_keys(CONFORMANCE_KEY_TYPE_OTHER)
301            .map_err(|err| format!("list_keys failed: {err}"))?;
302        if other.len() != 1 || other.first().map(String::as_str) != Some("shared_id") {
303            return Err(format!("list_keys leaked across key types: {other:?}"));
304        }
305        Ok(())
306    })();
307    report.check("list_keys_is_accurate_and_scoped", outcome);
308
309    // 9. Listing an untouched key type is empty, not an error. A fresh
310    // install lists every category before it has written one.
311    let outcome = match storage.list_keys(CONFORMANCE_KEY_TYPE_UNUSED) {
312        Ok(keys) if keys.is_empty() => Ok(()),
313        Ok(keys) => Err(format!("an unused key type listed {} keys", keys.len())),
314        Err(err) => Err(format!("list_keys on an unused key type failed: {err}")),
315    };
316    report.check("unused_key_type_lists_empty", outcome);
317
318    // 10. Key ids as the SDK actually writes them: a UUID for a key
319    // package, a 64-character digest for an OpenMLS record, and a session
320    // id, which is `session:<address>:<address>`. The colons are the one
321    // that bites. A backend that composes `type:id` into one string has to
322    // take everything after the first colon back as the id; one that takes
323    // the second field of a full split, or splits on the last colon,
324    // returns the wrong id from `list_keys`, and every 1:1 session is lost
325    // on restart.
326    let session_id = "session:off1qq6k7tx3adkz9gkvwt6c5c4v8lkh5g66ru6jm3s0dm\
327                      :off1qq7wtkw2pmld2pcj3zn5fvhhj3kj5sgnlwq0mnh2yg7d";
328    let ids = [
329        ("a key package id", "8f2c4d3a-4b6e-4c1f-9d0a-1e2f3a4b5c6d"),
330        (
331            "an OpenMLS record id",
332            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
333        ),
334        ("a session id", session_id),
335    ];
336    let outcome = (|| -> Result<(), String> {
337        for (what, id) in ids {
338            store_and_read_back(storage, id, what.as_bytes(), what)?;
339        }
340        let keys = storage
341            .list_keys(CONFORMANCE_KEY_TYPE)
342            .map_err(|err| format!("list_keys failed: {err}"))?;
343        for (what, id) in ids {
344            if !keys.iter().any(|key| key == id) {
345                return Err(format!("list_keys did not return {what} verbatim"));
346            }
347        }
348        Ok(())
349    })();
350    report.check("key_ids_as_the_sdk_writes_them", outcome);
351
352    // 11. Key ids are compared byte for byte. Group ids are chosen by
353    // whoever creates the group, a peer included, and `GroupId` accepts
354    // upper case, any Unicode, and names a filesystem treats specially. A
355    // backend that folds case (a file per record on APFS or NTFS, a NOCASE
356    // column) or normalises Unicode keeps two groups in one record, and
357    // serves one group's secrets for the other's.
358    let exact_ids: [(&str, &[u8]); 7] = [
359        ("group:Team", b"upper case"),
360        ("group:team", b"lower case"),
361        ("group:caf\u{e9}", b"composed"),
362        ("group:cafe\u{301}", b"decomposed"),
363        ("group:CON", b"a reserved device name"),
364        ("group:a*b?", b"wildcards"),
365        ("group:abc.", b"a trailing dot"),
366    ];
367    let outcome = (|| -> Result<(), String> {
368        for (id, _) in exact_ids {
369            if GroupId::new(id).is_err() {
370                return Err(format!("{id:?} is not a valid group id; fix the suite"));
371            }
372        }
373        for (id, value) in exact_ids {
374            storage
375                .store(CONFORMANCE_KEY_TYPE, id, value)
376                .map_err(|err| format!("store of {id:?} failed: {err}"))?;
377        }
378        for (id, value) in exact_ids {
379            expect_load(storage, CONFORMANCE_KEY_TYPE, id, value)
380                .map_err(|detail| format!("{id:?}: {detail}"))?;
381        }
382        let keys = storage
383            .list_keys(CONFORMANCE_KEY_TYPE)
384            .map_err(|err| format!("list_keys failed: {err}"))?;
385        for (id, _) in exact_ids {
386            if !keys.iter().any(|key| key == id) {
387                return Err(format!("list_keys did not return {id:?} verbatim"));
388            }
389        }
390        Ok(())
391    })();
392    report.check("key_ids_are_byte_exact", outcome);
393
394    // 12. The longest key id the SDK can write: a group id at its cap,
395    // which is the wire format's string-field cap, because group state is
396    // keyed by the id itself. Probing anything shorter lets a backend with
397    // a limit in between pass here and fail in production on a group a
398    // peer created.
399    let long_key = format!(
400        "session:{}",
401        "a".repeat(GroupId::MAX_LEN - "session:".len())
402    );
403    let outcome = (|| -> Result<(), String> {
404        if GroupId::new(long_key.as_str()).is_err() {
405            return Err("the probe id is not a valid group id; fix the suite".to_string());
406        }
407        store_and_read_back(storage, &long_key, b"long", "a maximum-length key id")
408    })();
409    report.check("long_key_ids_round_trip", outcome);
410
411    // 13. A record at the size a large group's ratchet tree reaches. A
412    // backend with a value limit in between (a credential store, a
413    // preferences string) fails on exactly the record whose loss takes the
414    // whole group with it.
415    let large = vec![0xa5u8; LARGE_RECORD_BYTES];
416    report.check(
417        "large_records_round_trip",
418        store_and_read_back(storage, "large", &large, "a large record"),
419    );
420
421    // 14. `exists` agrees with `load`. No SDK path calls it today (the
422    // identity is found with `load`), but it is trait surface a host may
423    // call. It has a default over `load`, so only an override can disagree,
424    // and one that does tells its caller a record is missing when it is
425    // not.
426    let outcome = (|| -> Result<(), String> {
427        let describe = |err: StorageError| format!("exists failed: {err}");
428        if storage
429            .exists(CONFORMANCE_KEY_TYPE, "exists_probe")
430            .map_err(describe)?
431        {
432            return Err("exists reported a key that was never written".to_string());
433        }
434        storage
435            .store(CONFORMANCE_KEY_TYPE, "exists_probe", b"x")
436            .map_err(|err| format!("store failed: {err}"))?;
437        if !storage
438            .exists(CONFORMANCE_KEY_TYPE, "exists_probe")
439            .map_err(describe)?
440        {
441            return Err("exists missed a key that was just stored".to_string());
442        }
443        if storage
444            .exists(CONFORMANCE_KEY_TYPE_OTHER, "exists_probe")
445            .map_err(describe)?
446        {
447            return Err("exists found a key under the wrong key type".to_string());
448        }
449        storage
450            .delete(CONFORMANCE_KEY_TYPE, "exists_probe")
451            .map_err(|err| format!("delete failed: {err}"))?;
452        if storage
453            .exists(CONFORMANCE_KEY_TYPE, "exists_probe")
454            .map_err(describe)?
455        {
456            return Err("exists reported a deleted key".to_string());
457        }
458        Ok(())
459    })();
460    report.check("exists_agrees_with_load", outcome);
461
462    // 15. `clear_type` empties one category and only that one. No SDK path
463    // calls it today (key packages are dropped one listed id at a time),
464    // but it is trait surface a host may call to reset a category, and a
465    // backend that clears everything takes the identity with it. This is
466    // the check that cannot be made safe before it runs; see the module
467    // documentation.
468    let outcome = (|| -> Result<(), String> {
469        storage
470            .store(CONFORMANCE_KEY_TYPE_OTHER, "survivor", b"kept")
471            .map_err(|err| format!("store failed: {err}"))?;
472        storage
473            .clear_type(CONFORMANCE_KEY_TYPE)
474            .map_err(|err| format!("clear_type failed: {err}"))?;
475        let cleared = storage
476            .list_keys(CONFORMANCE_KEY_TYPE)
477            .map_err(|err| format!("list_keys failed: {err}"))?;
478        if !cleared.is_empty() {
479            return Err(format!(
480                "{} records survived clear_type on their own key type",
481                cleared.len()
482            ));
483        }
484        expect_load(storage, CONFORMANCE_KEY_TYPE_OTHER, "survivor", b"kept")
485            .map_err(|_| "clear_type reached into another key type".to_string())?;
486        expect_load(storage, CONFORMANCE_KEY_TYPE_OTHER, "shared_id", b"theirs")
487            .map_err(|_| "clear_type reached into another key type".to_string())?;
488        storage
489            .clear_type(CONFORMANCE_KEY_TYPE)
490            .map_err(|err| format!("clear_type on an emptied key type failed: {err}"))?;
491        storage
492            .clear_type(CONFORMANCE_KEY_TYPE_UNUSED)
493            .map_err(|err| format!("clear_type on an unused key type failed: {err}"))?;
494        Ok(())
495    })();
496    report.check("clear_type_is_scoped_and_idempotent", outcome);
497
498    // 16. Writes from several threads land whole. The trait requires
499    // `Send + Sync` and a per-entry atomic commit; the engine takes it at
500    // its word and stores from the event thread while the manager reads
501    // from a caller's. A backend that copies without a lock hands back a
502    // value that is half one epoch and half the next.
503    report.check("concurrent_writes_land_whole", concurrent_writes(storage));
504
505    // 17. Everything written can be found and removed. This is the logout
506    // contract: wiping an account has to actually empty the backend.
507    let outcome = (|| -> Result<(), String> {
508        cleanup(storage);
509        for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
510            let remaining = storage
511                .list_keys(key_type)
512                .map_err(|err| format!("list_keys failed: {err}"))?;
513            if !remaining.is_empty() {
514                return Err(format!(
515                    "{} records survived a listed delete in {key_type}",
516                    remaining.len()
517                ));
518            }
519        }
520        Ok(())
521    })();
522    report.check("listed_records_can_all_be_deleted", outcome);
523
524    report
525}
526
527/// The isolation check: the same id under two key types, and an id under
528/// one key type that must not appear in the other's listing.
529///
530/// Only a collision fails it. A value that comes back wrong in some other
531/// way is the round-trip checks' to report, and a listing that fails is the
532/// listing checks'; neither is evidence that a listed delete would reach
533/// outside the probe key types. The probe records are removed with point
534/// deletes whatever the outcome.
535fn key_types_are_isolated(storage: &dyn MlsStorage) -> Result<(), String> {
536    let outcome = (|| -> Result<(), String> {
537        storage
538            .store(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID, b"theirs")
539            .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE, ISOLATION_PROBE_ID, b"mine"))
540            .and_then(|()| {
541                storage.store(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_LISTING_PROBE_ID, b"x")
542            })
543            .and_then(|()| {
544                storage.store(CONFORMANCE_KEY_TYPE_SUFFIXED, ISOLATION_LISTING_PROBE_ID, b"x")
545            })
546            .map_err(|err| {
547                format!("store failed, so isolation could not be checked and nothing was deleted: {err}")
548            })?;
549        if let Ok(Some(other)) = storage.load(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID) {
550            if other == b"mine" {
551                return Err(
552                    "the same key id in two key types collided: a write under one \
553                            replaced the other"
554                        .to_string(),
555                );
556            }
557        }
558        if let Ok(listed) = storage.list_keys(CONFORMANCE_KEY_TYPE) {
559            if listed.iter().any(|key| key == ISOLATION_LISTING_PROBE_ID) {
560                return Err("list_keys named a record written under another key type".to_string());
561            }
562        }
563        Ok(())
564    })();
565    for (key_type, key_id) in [
566        (CONFORMANCE_KEY_TYPE, ISOLATION_PROBE_ID),
567        (CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID),
568        (CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_LISTING_PROBE_ID),
569        (CONFORMANCE_KEY_TYPE_SUFFIXED, ISOLATION_LISTING_PROBE_ID),
570    ] {
571        let _ = storage.delete(key_type, key_id);
572    }
573    outcome
574}
575
576/// The concurrency check: each writer overwrites its own key and one key
577/// shared by all of them, reading the shared key back between writes.
578///
579/// Every value is one writer's fill byte repeated, so a torn read is any
580/// value that is not exactly one writer's; nothing has to be timed.
581fn concurrent_writes(storage: &dyn MlsStorage) -> Result<(), String> {
582    const SHARED_KEY: &str = "concurrent_shared";
583    let shared_values: Vec<Vec<u8>> = (0..CONCURRENT_WRITERS)
584        .map(|writer| vec![writer as u8 + 1; CONCURRENT_VALUE_BYTES])
585        .collect();
586
587    let own_value = |writer: usize, round: usize| -> Vec<u8> {
588        let mut value = vec![writer as u8 + 1; CONCURRENT_VALUE_BYTES];
589        value[0] = round as u8;
590        value
591    };
592
593    let outcomes: Vec<Result<(), String>> = std::thread::scope(|scope| {
594        let handles: Vec<_> = (0..CONCURRENT_WRITERS)
595            .map(|writer| {
596                let shared_values = &shared_values;
597                let own_value = &own_value;
598                std::thread::Builder::new().spawn_scoped(scope, move || -> Result<(), String> {
599                    let own_key = format!("concurrent_writer_{writer}");
600                    for round in 0..WRITES_PER_WRITER {
601                        storage
602                            .store(CONFORMANCE_KEY_TYPE, &own_key, &own_value(writer, round))
603                            .map_err(|err| format!("writer {writer}: store failed: {err}"))?;
604                        storage
605                            .store(CONFORMANCE_KEY_TYPE, SHARED_KEY, &shared_values[writer])
606                            .map_err(|err| format!("writer {writer}: store failed: {err}"))?;
607                        match storage.load(CONFORMANCE_KEY_TYPE, SHARED_KEY) {
608                            Ok(Some(seen)) if shared_values.contains(&seen) => {}
609                            Ok(Some(seen)) => {
610                                return Err(format!(
611                                    "writer {writer} read a torn value ({} bytes) from a key \
612                                     under concurrent overwrite",
613                                    seen.len()
614                                ))
615                            }
616                            Ok(None) => {
617                                return Err(format!(
618                                    "writer {writer} found a key it had just written missing \
619                                     under concurrent overwrite"
620                                ))
621                            }
622                            Err(err) => {
623                                return Err(format!(
624                                    "writer {writer}: load under concurrent writes failed: {err}"
625                                ))
626                            }
627                        }
628                    }
629                    expect_load(
630                        storage,
631                        CONFORMANCE_KEY_TYPE,
632                        &own_key,
633                        &own_value(writer, WRITES_PER_WRITER - 1),
634                    )
635                    .map_err(|detail| format!("writer {writer}'s own key: {detail}"))
636                })
637            })
638            .collect();
639        handles
640            .into_iter()
641            .map(|spawned| match spawned {
642                Ok(handle) => handle.join().unwrap_or_else(|_| {
643                    Err("a writer thread panicked inside the adapter".to_string())
644                }),
645                Err(err) => Err(format!("could not start a writer thread: {err}")),
646            })
647            .collect()
648    });
649    for outcome in outcomes {
650        outcome?;
651    }
652
653    match storage.load(CONFORMANCE_KEY_TYPE, SHARED_KEY) {
654        Ok(Some(seen)) if shared_values.contains(&seen) => Ok(()),
655        Ok(Some(seen)) => Err(format!(
656            "after all writers finished the shared key held a torn value ({} bytes)",
657            seen.len()
658        )),
659        Ok(None) => Err("after all writers finished the shared key was missing".to_string()),
660        Err(err) => Err(format!("load after concurrent writes failed: {err}")),
661    }
662}
663
664/// Remove every record the suite writes, in both key types.
665fn cleanup(storage: &dyn MlsStorage) {
666    for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
667        if let Ok(keys) = storage.list_keys(key_type) {
668            for key in keys {
669                let _ = storage.delete(key_type, &key);
670            }
671        }
672    }
673}
674
675/// Run the suite and render the report as JSON.
676///
677/// The same shape the protocol-state suite renders, so one reader serves
678/// both reports.
679pub fn run_json(storage: &dyn MlsStorage) -> String {
680    let report = run(storage);
681    serde_json::to_string(&report).unwrap_or_else(|err| {
682        format!(
683            "{{\"passed\":[],\"failures\":[{{\"check\":\"report_serialization\",\"detail\":\"{}\"}}]}}",
684            err.to_string().replace('"', "'")
685        )
686    })
687}
688
689#[cfg(test)]
690mod tests {
691    use std::collections::HashMap;
692    use std::sync::Mutex;
693
694    use super::*;
695    use crate::storage::{InMemoryStorage, StorageResult};
696
697    #[test]
698    fn the_suite_is_green_against_the_in_memory_store() {
699        let storage = InMemoryStorage::new();
700        let report = run(&storage);
701        assert!(report.is_green(), "{}", report.summary());
702        assert_eq!(report.passed.len(), 17, "{:?}", report.passed);
703        for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
704            assert!(
705                storage.list_keys(key_type).unwrap().is_empty(),
706                "the suite left records behind in {key_type}"
707            );
708        }
709    }
710
711    #[test]
712    fn the_suite_leaves_records_outside_its_probe_key_types_alone() {
713        let storage = InMemoryStorage::new();
714        storage.store("identity", "key_pair", b"secret").unwrap();
715        let report = run(&storage);
716        assert!(report.is_green(), "{}", report.summary());
717        assert_eq!(
718            storage.load("identity", "key_pair").unwrap(),
719            Some(b"secret".to_vec())
720        );
721        assert_eq!(storage.list_keys("identity").unwrap(), vec!["key_pair"]);
722    }
723
724    #[test]
725    fn the_report_renders_the_documented_json_shape() {
726        let json = run_json(&InMemoryStorage::new());
727        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
728        assert!(parsed["passed"].as_array().is_some_and(|p| p.len() == 17));
729        assert!(parsed["failures"].as_array().is_some_and(Vec::is_empty));
730
731        let mut report = ConformanceReport::new();
732        report.fail("x", "why");
733        let parsed: serde_json::Value =
734            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
735        assert_eq!(parsed["failures"][0]["check"], "x");
736        assert_eq!(parsed["failures"][0]["detail"], "why");
737    }
738
739    /// One defect at a time, each a class of bug an adapter can ship with
740    /// while returning `Ok` from every method.
741    #[derive(Debug, Clone, Copy)]
742    enum Fault {
743        None,
744        CorruptsValues,
745        StringTypedValues,
746        EmptyReadsAsMissing,
747        KeepsFirstWrite,
748        AbsentIsAnError,
749        DeleteOfAbsentFails,
750        IgnoresKeyType,
751        ListsBySuffix,
752        ListReturnsFirstPageOnly,
753        ListOfUnknownTypeFails,
754        KeepsIdUpToFirstColon,
755        CaseFoldsKeyIds,
756        CapsKeyIdAt255,
757        TruncatesValuesAt64KiB,
758        ExistsAlwaysFalse,
759        ClearTypeClearsEverything,
760        TearsOverwrites,
761        DeleteIsANoOp,
762    }
763
764    struct Faulty {
765        fault: Fault,
766        records: Mutex<HashMap<(String, String), Vec<u8>>>,
767        previous: Mutex<HashMap<(String, String), Vec<u8>>>,
768    }
769
770    impl Faulty {
771        fn new(fault: Fault) -> Self {
772            Self {
773                fault,
774                records: Mutex::default(),
775                previous: Mutex::default(),
776            }
777        }
778
779        fn key(&self, key_type: &str, key_id: &str) -> (String, String) {
780            let key_type = match self.fault {
781                Fault::IgnoresKeyType => "",
782                _ => key_type,
783            };
784            let key_id = match self.fault {
785                Fault::KeepsIdUpToFirstColon => key_id.split(':').next().unwrap_or("").to_string(),
786                Fault::CaseFoldsKeyIds => key_id.to_lowercase(),
787                _ => key_id.to_string(),
788            };
789            (key_type.to_string(), key_id)
790        }
791    }
792
793    impl MlsStorage for Faulty {
794        fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> StorageResult<()> {
795            if matches!(self.fault, Fault::CapsKeyIdAt255) && key_id.len() > 255 {
796                return Err(StorageError::StoreFailed("key too long".to_string()));
797            }
798            let mut data = data.to_vec();
799            if matches!(self.fault, Fault::TruncatesValuesAt64KiB) {
800                data.truncate(64 * 1024);
801            }
802            if matches!(self.fault, Fault::StringTypedValues) {
803                data = String::from_utf8_lossy(&data).into_owned().into_bytes();
804            }
805            let key = self.key(key_type, key_id);
806            let mut records = self.records.lock().unwrap();
807            if matches!(self.fault, Fault::KeepsFirstWrite) {
808                records.entry(key).or_insert(data);
809                return Ok(());
810            }
811            if let Some(old) = records.insert(key.clone(), data) {
812                self.previous.lock().unwrap().insert(key, old);
813            }
814            Ok(())
815        }
816
817        fn load(&self, key_type: &str, key_id: &str) -> StorageResult<Option<Vec<u8>>> {
818            let key = self.key(key_type, key_id);
819            let current = self.records.lock().unwrap().get(&key).cloned();
820            match (self.fault, current) {
821                (Fault::AbsentIsAnError, None) => {
822                    Err(StorageError::KeyNotFound(key_id.to_string()))
823                }
824                (Fault::EmptyReadsAsMissing, Some(current)) if current.is_empty() => Ok(None),
825                (Fault::CorruptsValues, Some(mut current)) if !current.is_empty() => {
826                    current[0] ^= 0xff;
827                    Ok(Some(current))
828                }
829                (Fault::TearsOverwrites, Some(current)) => {
830                    // Half the previous value and half the current one: what
831                    // an unlocked copy hands a reader mid-overwrite.
832                    let previous = self.previous.lock().unwrap().get(&key).cloned();
833                    match previous {
834                        Some(previous)
835                            if previous != current && previous.len() == current.len() =>
836                        {
837                            let half = current.len() / 2;
838                            let mut torn = previous[..half].to_vec();
839                            torn.extend_from_slice(&current[half..]);
840                            Ok(Some(torn))
841                        }
842                        _ => Ok(Some(current)),
843                    }
844                }
845                (_, current) => Ok(current),
846            }
847        }
848
849        fn delete(&self, key_type: &str, key_id: &str) -> StorageResult<()> {
850            if matches!(self.fault, Fault::DeleteIsANoOp) {
851                return Ok(());
852            }
853            let key = self.key(key_type, key_id);
854            let removed = self.records.lock().unwrap().remove(&key);
855            if removed.is_none() && matches!(self.fault, Fault::DeleteOfAbsentFails) {
856                return Err(StorageError::KeyNotFound(key_id.to_string()));
857            }
858            Ok(())
859        }
860
861        fn list_keys(&self, key_type: &str) -> StorageResult<Vec<String>> {
862            let (key_type, _) = self.key(key_type, "");
863            let keys: Vec<String> = self
864                .records
865                .lock()
866                .unwrap()
867                .keys()
868                .filter(|(kt, _)| match self.fault {
869                    // Matching the tail of the name, which the SDK's own
870                    // key types share (see CONFORMANCE_KEY_TYPE_SUFFIXED).
871                    Fault::ListsBySuffix => kt.ends_with(key_type.as_str()),
872                    _ => *kt == key_type,
873                })
874                .map(|(_, id)| id.clone())
875                .collect();
876            match self.fault {
877                // A paginated backend handing back its first page only.
878                Fault::ListReturnsFirstPageOnly => Ok(keys.into_iter().take(4).collect()),
879                // A backend that treats a missing directory or table as
880                // an error rather than as an empty category.
881                Fault::ListOfUnknownTypeFails if keys.is_empty() => Err(StorageError::LoadFailed(
882                    format!("no such key type: {key_type}"),
883                )),
884                _ => Ok(keys),
885            }
886        }
887
888        fn exists(&self, key_type: &str, key_id: &str) -> StorageResult<bool> {
889            if matches!(self.fault, Fault::ExistsAlwaysFalse) {
890                return Ok(false);
891            }
892            Ok(self.load(key_type, key_id)?.is_some())
893        }
894
895        fn clear_type(&self, key_type: &str) -> StorageResult<()> {
896            let mut records = self.records.lock().unwrap();
897            if matches!(self.fault, Fault::ClearTypeClearsEverything) {
898                records.clear();
899                return Ok(());
900            }
901            let (key_type, _) = self.key(key_type, "");
902            records.retain(|(kt, _), _| *kt != key_type);
903            Ok(())
904        }
905    }
906
907    #[test]
908    fn the_fault_injecting_store_is_green_with_no_fault() {
909        // Otherwise a failure below could be the harness's, not the fault's.
910        let report = run(&Faulty::new(Fault::None));
911        assert!(report.is_green(), "{}", report.summary());
912    }
913
914    #[test]
915    fn the_suite_catches_each_defect_class() {
916        // The negative controls. A suite that passes everything proves
917        // nothing, so each check is shown failing the defect it exists for.
918        let cases = [
919            (Fault::IgnoresKeyType, "key_types_are_separate_namespaces"),
920            (Fault::ListsBySuffix, "key_types_are_separate_namespaces"),
921            (Fault::CorruptsValues, "store_then_load"),
922            (Fault::StringTypedValues, "binary_values_round_trip"),
923            (Fault::EmptyReadsAsMissing, "empty_value_is_not_missing"),
924            (Fault::KeepsFirstWrite, "store_overwrites"),
925            (Fault::AbsentIsAnError, "absent_key_loads_as_none"),
926            (
927                Fault::DeleteOfAbsentFails,
928                "delete_removes_and_is_idempotent",
929            ),
930            (
931                Fault::ListReturnsFirstPageOnly,
932                "list_keys_is_accurate_and_scoped",
933            ),
934            (Fault::ListOfUnknownTypeFails, "unused_key_type_lists_empty"),
935            (
936                Fault::KeepsIdUpToFirstColon,
937                "key_ids_as_the_sdk_writes_them",
938            ),
939            (Fault::CaseFoldsKeyIds, "key_ids_are_byte_exact"),
940            (Fault::CapsKeyIdAt255, "long_key_ids_round_trip"),
941            (Fault::TruncatesValuesAt64KiB, "large_records_round_trip"),
942            (Fault::ExistsAlwaysFalse, "exists_agrees_with_load"),
943            (
944                Fault::ClearTypeClearsEverything,
945                "clear_type_is_scoped_and_idempotent",
946            ),
947            (Fault::TearsOverwrites, "concurrent_writes_land_whole"),
948            (Fault::DeleteIsANoOp, "listed_records_can_all_be_deleted"),
949        ];
950        // Every check the suite runs has a control here, so a check added
951        // without one fails this test rather than shipping unproven.
952        let covered: std::collections::BTreeSet<&str> =
953            cases.iter().map(|(_, check)| *check).collect();
954        let checks: std::collections::BTreeSet<String> =
955            run(&InMemoryStorage::new()).passed.into_iter().collect();
956        let uncovered: Vec<&String> = checks
957            .iter()
958            .filter(|check| !covered.contains(check.as_str()))
959            .collect();
960        assert!(
961            uncovered.is_empty(),
962            "checks with no negative control: {uncovered:?}"
963        );
964
965        for (fault, expected) in cases {
966            let report = run(&Faulty::new(fault));
967            assert!(!report.is_green(), "{fault:?} passed the suite");
968            assert!(
969                report.failures.iter().any(|f| f.check == expected),
970                "{fault:?}: expected {expected} to fail, got: {}",
971                report.summary()
972            );
973        }
974    }
975
976    #[test]
977    fn the_absent_key_check_names_the_wrong_error_variant() {
978        let report = run(&Faulty::new(Fault::AbsentIsAnError));
979        let failure = report
980            .failures
981            .iter()
982            .find(|f| f.check == "absent_key_loads_as_none")
983            .expect("the check failed");
984        assert!(failure.detail.contains("KeyNotFound"), "{}", failure.detail);
985    }
986    #[test]
987    fn a_backend_that_lists_by_suffix_stops_the_run_before_any_delete() {
988        // Listing one key type by the tail of its name reaches every type
989        // that ends the same way. The run stops at the isolation check, and
990        // a real record in such a type is untouched.
991        let storage = Faulty::new(Fault::ListsBySuffix);
992        storage
993            .store("prefixed_mls_storage_conformance_probe", "real", b"kept")
994            .unwrap();
995        let report = run(&storage);
996        assert!(report.passed.is_empty(), "{:?}", report.passed);
997        assert_eq!(
998            report.failures[0].check,
999            "key_types_are_separate_namespaces"
1000        );
1001        assert_eq!(
1002            storage
1003                .load("prefixed_mls_storage_conformance_probe", "real")
1004                .unwrap(),
1005            Some(b"kept".to_vec())
1006        );
1007    }
1008
1009    #[test]
1010    fn a_backend_that_merges_key_types_stops_the_run_before_any_delete() {
1011        // The isolation check runs first because every listed delete after
1012        // it trusts that listing a probe key type names only probe records.
1013        // On this backend it names the identity too, so a run that got as
1014        // far as its cleanup would delete it.
1015        let storage = Faulty::new(Fault::IgnoresKeyType);
1016        storage.store("identity", "key_pair", b"secret").unwrap();
1017        storage.store("group_state", "group:abc", b"state").unwrap();
1018        let report = run(&storage);
1019        assert!(report.passed.is_empty(), "{:?}", report.passed);
1020        assert_eq!(report.failures.len(), 1, "{}", report.summary());
1021        assert_eq!(
1022            report.failures[0].check,
1023            "key_types_are_separate_namespaces"
1024        );
1025        assert_eq!(
1026            storage.load("identity", "key_pair").unwrap(),
1027            Some(b"secret".to_vec())
1028        );
1029        assert_eq!(
1030            storage.load("group_state", "group:abc").unwrap(),
1031            Some(b"state".to_vec())
1032        );
1033    }
1034}