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.
581///
582/// A target with no threads (a browser worker, WASI preview 1) cannot start
583/// a second writer, and there nothing can overwrite a key while another
584/// write to it is in progress. There the same writers take turns on one
585/// thread, a round each, so the shared key is still overwritten by every
586/// writer between reads. Without that the check would fail on such a target
587/// for a reason that has nothing to do with the adapter, and an adapter that
588/// runs there could never be green.
589fn concurrent_writes(storage: &dyn MlsStorage) -> Result<(), String> {
590    let shared_values: Vec<Vec<u8>> = (0..CONCURRENT_WRITERS)
591        .map(|writer| vec![writer as u8 + 1; CONCURRENT_VALUE_BYTES])
592        .collect();
593
594    #[cfg(not(all(target_family = "wasm", not(target_feature = "atomics"))))]
595    let outcomes: Vec<Result<(), String>> = std::thread::scope(|scope| {
596        let handles: Vec<_> = (0..CONCURRENT_WRITERS)
597            .map(|writer| {
598                let shared_values = &shared_values;
599                std::thread::Builder::new().spawn_scoped(scope, move || -> Result<(), String> {
600                    for round in 0..WRITES_PER_WRITER {
601                        write_round(storage, shared_values, writer, round)?;
602                    }
603                    check_own_key(storage, writer)
604                })
605            })
606            .collect();
607        handles
608            .into_iter()
609            .map(|spawned| match spawned {
610                Ok(handle) => handle.join().unwrap_or_else(|_| {
611                    Err("a writer thread panicked inside the adapter".to_string())
612                }),
613                Err(err) => Err(format!("could not start a writer thread: {err}")),
614            })
615            .collect()
616    });
617    #[cfg(all(target_family = "wasm", not(target_feature = "atomics")))]
618    let outcomes: Vec<Result<(), String>> = {
619        let mut outcomes = Vec::with_capacity(CONCURRENT_WRITERS + 1);
620        outcomes.push((|| {
621            for round in 0..WRITES_PER_WRITER {
622                for writer in 0..CONCURRENT_WRITERS {
623                    write_round(storage, &shared_values, writer, round)?;
624                }
625            }
626            Ok(())
627        })());
628        outcomes.extend((0..CONCURRENT_WRITERS).map(|writer| check_own_key(storage, writer)));
629        outcomes
630    };
631    for outcome in outcomes {
632        outcome?;
633    }
634
635    match storage.load(CONFORMANCE_KEY_TYPE, CONCURRENT_SHARED_KEY) {
636        Ok(Some(seen)) if shared_values.contains(&seen) => Ok(()),
637        Ok(Some(seen)) => Err(format!(
638            "after all writers finished the shared key held a torn value ({} bytes)",
639            seen.len()
640        )),
641        Ok(None) => Err("after all writers finished the shared key was missing".to_string()),
642        Err(err) => Err(format!("load after concurrent writes failed: {err}")),
643    }
644}
645
646/// The key every writer of the concurrency check overwrites.
647const CONCURRENT_SHARED_KEY: &str = "concurrent_shared";
648
649/// The value `writer` stores under its own key in `round`.
650fn concurrent_own_value(writer: usize, round: usize) -> Vec<u8> {
651    let mut value = vec![writer as u8 + 1; CONCURRENT_VALUE_BYTES];
652    value[0] = round as u8;
653    value
654}
655
656/// One round of one writer: its own key, the shared key, and a read of the
657/// shared key that must be exactly one writer's value.
658fn write_round(
659    storage: &dyn MlsStorage,
660    shared_values: &[Vec<u8>],
661    writer: usize,
662    round: usize,
663) -> Result<(), String> {
664    storage
665        .store(
666            CONFORMANCE_KEY_TYPE,
667            &format!("concurrent_writer_{writer}"),
668            &concurrent_own_value(writer, round),
669        )
670        .map_err(|err| format!("writer {writer}: store failed: {err}"))?;
671    storage
672        .store(
673            CONFORMANCE_KEY_TYPE,
674            CONCURRENT_SHARED_KEY,
675            &shared_values[writer],
676        )
677        .map_err(|err| format!("writer {writer}: store failed: {err}"))?;
678    match storage.load(CONFORMANCE_KEY_TYPE, CONCURRENT_SHARED_KEY) {
679        Ok(Some(seen)) if shared_values.contains(&seen) => Ok(()),
680        Ok(Some(seen)) => Err(format!(
681            "writer {writer} read a torn value ({} bytes) from a key under concurrent overwrite",
682            seen.len()
683        )),
684        Ok(None) => Err(format!(
685            "writer {writer} found a key it had just written missing under concurrent overwrite"
686        )),
687        Err(err) => Err(format!(
688            "writer {writer}: load under concurrent writes failed: {err}"
689        )),
690    }
691}
692
693/// A writer's own key holds its last round's value.
694fn check_own_key(storage: &dyn MlsStorage, writer: usize) -> Result<(), String> {
695    expect_load(
696        storage,
697        CONFORMANCE_KEY_TYPE,
698        &format!("concurrent_writer_{writer}"),
699        &concurrent_own_value(writer, WRITES_PER_WRITER - 1),
700    )
701    .map_err(|detail| format!("writer {writer}'s own key: {detail}"))
702}
703
704/// Remove every record the suite writes, in both key types.
705fn cleanup(storage: &dyn MlsStorage) {
706    for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
707        if let Ok(keys) = storage.list_keys(key_type) {
708            for key in keys {
709                let _ = storage.delete(key_type, &key);
710            }
711        }
712    }
713}
714
715/// Run the suite and render the report as JSON.
716///
717/// The same shape the protocol-state suite renders, so one reader serves
718/// both reports.
719pub fn run_json(storage: &dyn MlsStorage) -> String {
720    let report = run(storage);
721    serde_json::to_string(&report).unwrap_or_else(|err| {
722        format!(
723            "{{\"passed\":[],\"failures\":[{{\"check\":\"report_serialization\",\"detail\":\"{}\"}}]}}",
724            err.to_string().replace('"', "'")
725        )
726    })
727}
728
729#[cfg(test)]
730mod tests {
731    use std::collections::HashMap;
732    use std::sync::Mutex;
733
734    use super::*;
735    use crate::storage::{InMemoryStorage, StorageResult};
736
737    #[test]
738    fn the_suite_is_green_against_the_in_memory_store() {
739        let storage = InMemoryStorage::new();
740        let report = run(&storage);
741        assert!(report.is_green(), "{}", report.summary());
742        assert_eq!(report.passed.len(), 17, "{:?}", report.passed);
743        for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
744            assert!(
745                storage.list_keys(key_type).unwrap().is_empty(),
746                "the suite left records behind in {key_type}"
747            );
748        }
749    }
750
751    #[test]
752    fn the_suite_leaves_records_outside_its_probe_key_types_alone() {
753        let storage = InMemoryStorage::new();
754        storage.store("identity", "key_pair", b"secret").unwrap();
755        let report = run(&storage);
756        assert!(report.is_green(), "{}", report.summary());
757        assert_eq!(
758            storage.load("identity", "key_pair").unwrap(),
759            Some(b"secret".to_vec())
760        );
761        assert_eq!(storage.list_keys("identity").unwrap(), vec!["key_pair"]);
762    }
763
764    #[test]
765    fn the_report_renders_the_documented_json_shape() {
766        let json = run_json(&InMemoryStorage::new());
767        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
768        assert!(parsed["passed"].as_array().is_some_and(|p| p.len() == 17));
769        assert!(parsed["failures"].as_array().is_some_and(Vec::is_empty));
770
771        let mut report = ConformanceReport::new();
772        report.fail("x", "why");
773        let parsed: serde_json::Value =
774            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
775        assert_eq!(parsed["failures"][0]["check"], "x");
776        assert_eq!(parsed["failures"][0]["detail"], "why");
777    }
778
779    /// One defect at a time, each a class of bug an adapter can ship with
780    /// while returning `Ok` from every method.
781    #[derive(Debug, Clone, Copy)]
782    enum Fault {
783        None,
784        CorruptsValues,
785        StringTypedValues,
786        EmptyReadsAsMissing,
787        KeepsFirstWrite,
788        AbsentIsAnError,
789        DeleteOfAbsentFails,
790        IgnoresKeyType,
791        ListsBySuffix,
792        ListReturnsFirstPageOnly,
793        ListOfUnknownTypeFails,
794        KeepsIdUpToFirstColon,
795        CaseFoldsKeyIds,
796        CapsKeyIdAt255,
797        TruncatesValuesAt64KiB,
798        ExistsAlwaysFalse,
799        ClearTypeClearsEverything,
800        TearsOverwrites,
801        DeleteIsANoOp,
802    }
803
804    struct Faulty {
805        fault: Fault,
806        records: Mutex<HashMap<(String, String), Vec<u8>>>,
807        previous: Mutex<HashMap<(String, String), Vec<u8>>>,
808    }
809
810    impl Faulty {
811        fn new(fault: Fault) -> Self {
812            Self {
813                fault,
814                records: Mutex::default(),
815                previous: Mutex::default(),
816            }
817        }
818
819        fn key(&self, key_type: &str, key_id: &str) -> (String, String) {
820            let key_type = match self.fault {
821                Fault::IgnoresKeyType => "",
822                _ => key_type,
823            };
824            let key_id = match self.fault {
825                Fault::KeepsIdUpToFirstColon => key_id.split(':').next().unwrap_or("").to_string(),
826                Fault::CaseFoldsKeyIds => key_id.to_lowercase(),
827                _ => key_id.to_string(),
828            };
829            (key_type.to_string(), key_id)
830        }
831    }
832
833    impl MlsStorage for Faulty {
834        fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> StorageResult<()> {
835            if matches!(self.fault, Fault::CapsKeyIdAt255) && key_id.len() > 255 {
836                return Err(StorageError::StoreFailed("key too long".to_string()));
837            }
838            let mut data = data.to_vec();
839            if matches!(self.fault, Fault::TruncatesValuesAt64KiB) {
840                data.truncate(64 * 1024);
841            }
842            if matches!(self.fault, Fault::StringTypedValues) {
843                data = String::from_utf8_lossy(&data).into_owned().into_bytes();
844            }
845            let key = self.key(key_type, key_id);
846            let mut records = self.records.lock().unwrap();
847            if matches!(self.fault, Fault::KeepsFirstWrite) {
848                records.entry(key).or_insert(data);
849                return Ok(());
850            }
851            if let Some(old) = records.insert(key.clone(), data) {
852                self.previous.lock().unwrap().insert(key, old);
853            }
854            Ok(())
855        }
856
857        fn load(&self, key_type: &str, key_id: &str) -> StorageResult<Option<Vec<u8>>> {
858            let key = self.key(key_type, key_id);
859            let current = self.records.lock().unwrap().get(&key).cloned();
860            match (self.fault, current) {
861                (Fault::AbsentIsAnError, None) => {
862                    Err(StorageError::KeyNotFound(key_id.to_string()))
863                }
864                (Fault::EmptyReadsAsMissing, Some(current)) if current.is_empty() => Ok(None),
865                (Fault::CorruptsValues, Some(mut current)) if !current.is_empty() => {
866                    current[0] ^= 0xff;
867                    Ok(Some(current))
868                }
869                (Fault::TearsOverwrites, Some(current)) => {
870                    // Half the previous value and half the current one: what
871                    // an unlocked copy hands a reader mid-overwrite.
872                    let previous = self.previous.lock().unwrap().get(&key).cloned();
873                    match previous {
874                        Some(previous)
875                            if previous != current && previous.len() == current.len() =>
876                        {
877                            let half = current.len() / 2;
878                            let mut torn = previous[..half].to_vec();
879                            torn.extend_from_slice(&current[half..]);
880                            Ok(Some(torn))
881                        }
882                        _ => Ok(Some(current)),
883                    }
884                }
885                (_, current) => Ok(current),
886            }
887        }
888
889        fn delete(&self, key_type: &str, key_id: &str) -> StorageResult<()> {
890            if matches!(self.fault, Fault::DeleteIsANoOp) {
891                return Ok(());
892            }
893            let key = self.key(key_type, key_id);
894            let removed = self.records.lock().unwrap().remove(&key);
895            if removed.is_none() && matches!(self.fault, Fault::DeleteOfAbsentFails) {
896                return Err(StorageError::KeyNotFound(key_id.to_string()));
897            }
898            Ok(())
899        }
900
901        fn list_keys(&self, key_type: &str) -> StorageResult<Vec<String>> {
902            let (key_type, _) = self.key(key_type, "");
903            let keys: Vec<String> = self
904                .records
905                .lock()
906                .unwrap()
907                .keys()
908                .filter(|(kt, _)| match self.fault {
909                    // Matching the tail of the name, which the SDK's own
910                    // key types share (see CONFORMANCE_KEY_TYPE_SUFFIXED).
911                    Fault::ListsBySuffix => kt.ends_with(key_type.as_str()),
912                    _ => *kt == key_type,
913                })
914                .map(|(_, id)| id.clone())
915                .collect();
916            match self.fault {
917                // A paginated backend handing back its first page only.
918                Fault::ListReturnsFirstPageOnly => Ok(keys.into_iter().take(4).collect()),
919                // A backend that treats a missing directory or table as
920                // an error rather than as an empty category.
921                Fault::ListOfUnknownTypeFails if keys.is_empty() => Err(StorageError::LoadFailed(
922                    format!("no such key type: {key_type}"),
923                )),
924                _ => Ok(keys),
925            }
926        }
927
928        fn exists(&self, key_type: &str, key_id: &str) -> StorageResult<bool> {
929            if matches!(self.fault, Fault::ExistsAlwaysFalse) {
930                return Ok(false);
931            }
932            Ok(self.load(key_type, key_id)?.is_some())
933        }
934
935        fn clear_type(&self, key_type: &str) -> StorageResult<()> {
936            let mut records = self.records.lock().unwrap();
937            if matches!(self.fault, Fault::ClearTypeClearsEverything) {
938                records.clear();
939                return Ok(());
940            }
941            let (key_type, _) = self.key(key_type, "");
942            records.retain(|(kt, _), _| *kt != key_type);
943            Ok(())
944        }
945    }
946
947    #[test]
948    fn the_fault_injecting_store_is_green_with_no_fault() {
949        // Otherwise a failure below could be the harness's, not the fault's.
950        let report = run(&Faulty::new(Fault::None));
951        assert!(report.is_green(), "{}", report.summary());
952    }
953
954    #[test]
955    fn the_suite_catches_each_defect_class() {
956        // The negative controls. A suite that passes everything proves
957        // nothing, so each check is shown failing the defect it exists for.
958        let cases = [
959            (Fault::IgnoresKeyType, "key_types_are_separate_namespaces"),
960            (Fault::ListsBySuffix, "key_types_are_separate_namespaces"),
961            (Fault::CorruptsValues, "store_then_load"),
962            (Fault::StringTypedValues, "binary_values_round_trip"),
963            (Fault::EmptyReadsAsMissing, "empty_value_is_not_missing"),
964            (Fault::KeepsFirstWrite, "store_overwrites"),
965            (Fault::AbsentIsAnError, "absent_key_loads_as_none"),
966            (
967                Fault::DeleteOfAbsentFails,
968                "delete_removes_and_is_idempotent",
969            ),
970            (
971                Fault::ListReturnsFirstPageOnly,
972                "list_keys_is_accurate_and_scoped",
973            ),
974            (Fault::ListOfUnknownTypeFails, "unused_key_type_lists_empty"),
975            (
976                Fault::KeepsIdUpToFirstColon,
977                "key_ids_as_the_sdk_writes_them",
978            ),
979            (Fault::CaseFoldsKeyIds, "key_ids_are_byte_exact"),
980            (Fault::CapsKeyIdAt255, "long_key_ids_round_trip"),
981            (Fault::TruncatesValuesAt64KiB, "large_records_round_trip"),
982            (Fault::ExistsAlwaysFalse, "exists_agrees_with_load"),
983            (
984                Fault::ClearTypeClearsEverything,
985                "clear_type_is_scoped_and_idempotent",
986            ),
987            (Fault::TearsOverwrites, "concurrent_writes_land_whole"),
988            (Fault::DeleteIsANoOp, "listed_records_can_all_be_deleted"),
989        ];
990        // Every check the suite runs has a control here, so a check added
991        // without one fails this test rather than shipping unproven.
992        let covered: std::collections::BTreeSet<&str> =
993            cases.iter().map(|(_, check)| *check).collect();
994        let checks: std::collections::BTreeSet<String> =
995            run(&InMemoryStorage::new()).passed.into_iter().collect();
996        let uncovered: Vec<&String> = checks
997            .iter()
998            .filter(|check| !covered.contains(check.as_str()))
999            .collect();
1000        assert!(
1001            uncovered.is_empty(),
1002            "checks with no negative control: {uncovered:?}"
1003        );
1004
1005        for (fault, expected) in cases {
1006            let report = run(&Faulty::new(fault));
1007            assert!(!report.is_green(), "{fault:?} passed the suite");
1008            assert!(
1009                report.failures.iter().any(|f| f.check == expected),
1010                "{fault:?}: expected {expected} to fail, got: {}",
1011                report.summary()
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn the_absent_key_check_names_the_wrong_error_variant() {
1018        let report = run(&Faulty::new(Fault::AbsentIsAnError));
1019        let failure = report
1020            .failures
1021            .iter()
1022            .find(|f| f.check == "absent_key_loads_as_none")
1023            .expect("the check failed");
1024        assert!(failure.detail.contains("KeyNotFound"), "{}", failure.detail);
1025    }
1026    #[test]
1027    fn a_backend_that_lists_by_suffix_stops_the_run_before_any_delete() {
1028        // Listing one key type by the tail of its name reaches every type
1029        // that ends the same way. The run stops at the isolation check, and
1030        // a real record in such a type is untouched.
1031        let storage = Faulty::new(Fault::ListsBySuffix);
1032        storage
1033            .store("prefixed_mls_storage_conformance_probe", "real", b"kept")
1034            .unwrap();
1035        let report = run(&storage);
1036        assert!(report.passed.is_empty(), "{:?}", report.passed);
1037        assert_eq!(
1038            report.failures[0].check,
1039            "key_types_are_separate_namespaces"
1040        );
1041        assert_eq!(
1042            storage
1043                .load("prefixed_mls_storage_conformance_probe", "real")
1044                .unwrap(),
1045            Some(b"kept".to_vec())
1046        );
1047    }
1048
1049    #[test]
1050    fn a_backend_that_merges_key_types_stops_the_run_before_any_delete() {
1051        // The isolation check runs first because every listed delete after
1052        // it trusts that listing a probe key type names only probe records.
1053        // On this backend it names the identity too, so a run that got as
1054        // far as its cleanup would delete it.
1055        let storage = Faulty::new(Fault::IgnoresKeyType);
1056        storage.store("identity", "key_pair", b"secret").unwrap();
1057        storage.store("group_state", "group:abc", b"state").unwrap();
1058        let report = run(&storage);
1059        assert!(report.passed.is_empty(), "{:?}", report.passed);
1060        assert_eq!(report.failures.len(), 1, "{}", report.summary());
1061        assert_eq!(
1062            report.failures[0].check,
1063            "key_types_are_separate_namespaces"
1064        );
1065        assert_eq!(
1066            storage.load("identity", "key_pair").unwrap(),
1067            Some(b"secret".to_vec())
1068        );
1069        assert_eq!(
1070            storage.load("group_state", "group:abc").unwrap(),
1071            Some(b"state".to_vec())
1072        );
1073    }
1074}