Skip to main content

offline_protocol/
storage_conformance.rs

1//! A portable conformance suite for [`ProtocolStateStorage`] implementations.
2//!
3//! Swappable storage is only a property if it can be verified. Without a
4//! suite, "bring your own backend" is a promise: an adapter that looks right
5//! and returns `Ok` from every method can still lose records under an
6//! overwrite, leak keys between categories, or truncate a value at some
7//! internal limit, and the symptom arrives much later as data that quietly
8//! is not there.
9//!
10//! Green here is the definition of "this backend is supported".
11//!
12//! The suite drives the trait directly rather than going through the
13//! sealing chokepoint, because what is under test is the adapter's contract:
14//! bytes in, the same bytes out, addressed by `(key_type, key_id)`. It
15//! writes only under its own key types, and deletes everything it wrote.
16//!
17//! Key-type isolation is checked first, with point writes and point deletes
18//! of ids nothing else uses, and a failure ends the run there. Every listed
19//! delete after it trusts that listing a probe key type names only probe
20//! records; on a backend that merges key types it names real records too,
21//! and the cleanup would delete them. That makes the suite safe to run
22//! against a live store whatever the backend does with key types. It is not
23//! a promise for every defect: probe a new backend on a scratch instance
24//! until it is green.
25
26use crate::protocol_state_storage::{
27    ProtocolStateError, ProtocolStateStorage, MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES,
28};
29
30/// The key type every probe record is written under.
31///
32/// Deliberately not a real category: nothing else reads it, and a leftover
33/// record from an interrupted run is inert.
34pub const CONFORMANCE_KEY_TYPE: &str = "storage_conformance_probe";
35
36/// A second key type, used to prove categories do not bleed into each other.
37pub const CONFORMANCE_KEY_TYPE_OTHER: &str = "storage_conformance_probe_other";
38
39/// A key type that ends with [`CONFORMANCE_KEY_TYPE`]. The second probe type
40/// is related to the first by prefix only, and a backend that lists by the
41/// tail of a name is caught only by one related by suffix.
42const CONFORMANCE_KEY_TYPE_SUFFIXED: &str = "suffixed_storage_conformance_probe";
43
44/// The id the isolation check writes under two probe key types. Nothing else
45/// uses it, so a point write or delete of it cannot reach a real record even
46/// on a backend that ignores the key type.
47const ISOLATION_PROBE_ID: &str = "storage_conformance_isolation";
48
49/// The id the isolation check writes under the other probe key types only,
50/// to catch a listing that crosses key types.
51const ISOLATION_LISTING_PROBE_ID: &str = "storage_conformance_isolation_listing";
52
53/// The report types are the MLS suite's, re-exported, so the two suites
54/// render one JSON shape and one reader serves both.
55pub use offline_protocol_mls::storage_conformance::{ConformanceFailure, ConformanceReport};
56
57fn expect_load(
58    storage: &dyn ProtocolStateStorage,
59    key_id: &str,
60    expected: &[u8],
61) -> std::result::Result<(), String> {
62    match storage.load(CONFORMANCE_KEY_TYPE, key_id) {
63        Ok(Some(actual)) if actual == expected => Ok(()),
64        Ok(Some(actual)) => Err(format!(
65            "loaded {} bytes, expected {} (contents differ)",
66            actual.len(),
67            expected.len()
68        )),
69        Ok(None) => Err("stored value loaded back as missing".to_string()),
70        Err(err) => Err(format!("load failed: {err}")),
71    }
72}
73
74/// Run the suite.
75///
76/// Never panics: an adapter that misbehaves produces failures in the report,
77/// because the whole point is to hand an adapter author a list of what to
78/// fix rather than a stack trace.
79pub fn run(storage: &dyn ProtocolStateStorage) -> ConformanceReport {
80    let mut report = ConformanceReport::new();
81
82    // 1. Key types are separate namespaces, checked before anything is
83    // deleted. The data layer keeps documents, their delta logs and space
84    // indexes in three of them; a backend that merges them would serve a
85    // delta where a document was asked for. It is also the one defect that
86    // turns this suite destructive (see the module doc), so a failure ends
87    // the run here.
88    let isolation = key_types_are_isolated(storage);
89    let isolated = isolation.is_ok();
90    report.check("key_types_are_separate_namespaces", isolation);
91    if !isolated {
92        return report;
93    }
94
95    // Anything left from an interrupted earlier run would make the listing
96    // checks report failures that are not the adapter's fault.
97    cleanup(storage);
98
99    // 2. The basic contract.
100    let outcome = storage
101        .store(CONFORMANCE_KEY_TYPE, "basic", b"hello")
102        .map_err(|err| format!("store failed: {err}"))
103        .and_then(|()| expect_load(storage, "basic", b"hello"));
104    report.check("store_then_load", outcome);
105
106    // 3. Values are bytes, not text. A backend that round-trips through a
107    // string type mangles these, and the failure is invisible until a
108    // sealed record (which is ciphertext) comes back wrong.
109    let binary: Vec<u8> = (0u8..=255).collect();
110    let outcome = storage
111        .store(CONFORMANCE_KEY_TYPE, "binary", &binary)
112        .map_err(|err| format!("store failed: {err}"))
113        .and_then(|()| expect_load(storage, "binary", &binary));
114    report.check("binary_values_round_trip", outcome);
115
116    // 4. An empty value is a value, and must not read back as absent.
117    let outcome = storage
118        .store(CONFORMANCE_KEY_TYPE, "empty", b"")
119        .map_err(|err| format!("store failed: {err}"))
120        .and_then(|()| expect_load(storage, "empty", b""));
121    report.check("empty_value_is_not_missing", outcome);
122
123    // 5. A second store replaces; it does not append or refuse.
124    let outcome = storage
125        .store(CONFORMANCE_KEY_TYPE, "overwrite", b"first")
126        .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE, "overwrite", b"second"))
127        .map_err(|err| format!("store failed: {err}"))
128        .and_then(|()| expect_load(storage, "overwrite", b"second"));
129    report.check("store_overwrites", outcome);
130
131    // 6. A key that was never written reads as missing, not as an error.
132    let outcome = match storage.load(CONFORMANCE_KEY_TYPE, "never_written") {
133        Ok(None) => Ok(()),
134        Ok(Some(_)) => Err("a key that was never written returned a value".to_string()),
135        Err(ProtocolStateError::NotFound(_)) => {
136            Err("absent keys must load as Ok(None), not NotFound".to_string())
137        }
138        Err(err) => Err(format!("load of an absent key failed: {err}")),
139    };
140    report.check("absent_key_loads_as_none", outcome);
141
142    // 7. Delete removes, and deleting again is not an error. The data layer
143    // deletes folded delta records after a crash may already have taken
144    // them, so a delete that fails on an absent key turns a clean recovery
145    // into a spurious failure.
146    let outcome = storage
147        .store(CONFORMANCE_KEY_TYPE, "deletable", b"x")
148        .and_then(|()| storage.delete(CONFORMANCE_KEY_TYPE, "deletable"))
149        .map_err(|err| format!("store/delete failed: {err}"))
150        .and_then(|()| match storage.load(CONFORMANCE_KEY_TYPE, "deletable") {
151            Ok(None) => Ok(()),
152            Ok(Some(_)) => Err("value survived a delete".to_string()),
153            Err(err) => Err(format!("load after delete failed: {err}")),
154        })
155        .and_then(
156            |()| match storage.delete(CONFORMANCE_KEY_TYPE, "deletable") {
157                Ok(()) => Ok(()),
158                Err(err) => Err(format!("second delete must succeed, got: {err}")),
159            },
160        );
161    report.check("delete_removes_and_is_idempotent", outcome);
162
163    // 8. Listing names what is there, and only within its own key type.
164    let outcome = (|| -> std::result::Result<(), String> {
165        storage
166            .store(CONFORMANCE_KEY_TYPE, "shared_id", b"mine")
167            .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE_OTHER, "shared_id", b"theirs"))
168            .map_err(|err| format!("store failed: {err}"))?;
169        let keys = storage
170            .list_keys(CONFORMANCE_KEY_TYPE)
171            .map_err(|err| format!("list_keys failed: {err}"))?;
172        for expected in ["basic", "binary", "empty", "overwrite", "shared_id"] {
173            if !keys.iter().any(|key| key == expected) {
174                return Err(format!("list_keys omitted {expected:?}"));
175            }
176        }
177        if keys.iter().any(|key| key == "deletable") {
178            return Err("list_keys named a deleted record".to_string());
179        }
180        let other = storage
181            .list_keys(CONFORMANCE_KEY_TYPE_OTHER)
182            .map_err(|err| format!("list_keys failed: {err}"))?;
183        if other.len() != 1 || other.first().map(String::as_str) != Some("shared_id") {
184            return Err(format!("list_keys leaked across key types: {other:?}"));
185        }
186        Ok(())
187    })();
188    report.check("list_keys_is_accurate_and_scoped", outcome);
189
190    // 9. Listing an untouched key type is empty, not an error.
191    let outcome = match storage.list_keys("storage_conformance_probe_unused") {
192        Ok(keys) if keys.is_empty() => Ok(()),
193        Ok(keys) => Err(format!("an unused key type listed {} keys", keys.len())),
194        Err(err) => Err(format!("list_keys on an unused key type failed: {err}")),
195    };
196    report.check("unused_key_type_lists_empty", outcome);
197
198    // 10. Key ids as the SDK actually composes them. Document records are
199    // keyed `{space}/{doc}` and delta records `{space}/{doc}/{seq}`, so a
200    // backend that treats a key id as a filesystem path, or rejects the
201    // separator, breaks the data layer specifically.
202    let composed = "space-1/doc.name_2/000000000000002a";
203    let outcome = storage
204        .store(CONFORMANCE_KEY_TYPE, composed, b"composed")
205        .map_err(|err| format!("store with a composed key failed: {err}"))
206        .and_then(|()| expect_load(storage, composed, b"composed"))
207        .and_then(|()| {
208            let keys = storage
209                .list_keys(CONFORMANCE_KEY_TYPE)
210                .map_err(|err| format!("list_keys failed: {err}"))?;
211            if keys.iter().any(|key| key == composed) {
212                Ok(())
213            } else {
214                Err("list_keys did not return a composed key verbatim".to_string())
215            }
216        });
217    report.check("composed_key_ids_round_trip", outcome);
218
219    // 11. The longest key id the SDK can actually write: a delta record for a
220    // maximum-length space and document name, which is
221    // `{128}/{128}/{16 hex}` = 274 bytes. Probing anything shorter lets a
222    // backend with a limit in between pass here and fail in production on
223    // exactly the record type whose loss is silent.
224    // 128 is `offline_protocol_data::MAX_NAME_LEN`, written as a literal
225    // because this suite must still compile with the data layer switched off.
226    let long_key = format!("{}/{}/{:016x}", "s".repeat(128), "d".repeat(128), u64::MAX);
227    let outcome = storage
228        .store(CONFORMANCE_KEY_TYPE, &long_key, b"long")
229        .map_err(|err| format!("store with a long key failed: {err}"))
230        .and_then(|()| expect_load(storage, &long_key, b"long"));
231    report.check("long_key_ids_round_trip", outcome);
232
233    // 12. A record at the size the SDK is allowed to write. A sealed record
234    // may reach the record cap plus the seal's overhead, and the provider's
235    // own ceiling is the documented superset of that.
236    let large = vec![0xa5u8; MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES / 2];
237    let outcome = storage
238        .store(CONFORMANCE_KEY_TYPE, "large", &large)
239        .map_err(|err| format!("store of a large record failed: {err}"))
240        .and_then(|()| expect_load(storage, "large", &large));
241    report.check("large_records_round_trip", outcome);
242
243    // 13. Everything written can be found and removed. This is the logout
244    // contract: an application pointing documents at a custom backend needs
245    // wiping to actually empty it.
246    let outcome = (|| -> std::result::Result<(), String> {
247        cleanup(storage);
248        for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
249            let remaining = storage
250                .list_keys(key_type)
251                .map_err(|err| format!("list_keys failed: {err}"))?;
252            if !remaining.is_empty() {
253                return Err(format!(
254                    "{} records survived a listed delete in {key_type}",
255                    remaining.len()
256                ));
257            }
258        }
259        Ok(())
260    })();
261    report.check("listed_records_can_all_be_deleted", outcome);
262
263    report
264}
265
266/// The isolation check: the same id under two key types, and an id under
267/// two others that must not appear in the first one's listing.
268///
269/// Only a collision fails it. A value that comes back wrong in another way
270/// is the round-trip checks' to report, and a listing that fails is the
271/// listing checks'; neither is evidence that a listed delete would reach
272/// outside the probe key types. The probe records are removed with point
273/// deletes whatever the outcome.
274fn key_types_are_isolated(storage: &dyn ProtocolStateStorage) -> std::result::Result<(), String> {
275    let outcome = (|| -> std::result::Result<(), String> {
276        storage
277            .store(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID, b"theirs")
278            .and_then(|()| storage.store(CONFORMANCE_KEY_TYPE, ISOLATION_PROBE_ID, b"mine"))
279            .and_then(|()| {
280                storage.store(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_LISTING_PROBE_ID, b"x")
281            })
282            .and_then(|()| {
283                storage.store(CONFORMANCE_KEY_TYPE_SUFFIXED, ISOLATION_LISTING_PROBE_ID, b"x")
284            })
285            .map_err(|err| {
286                format!("store failed, so isolation could not be checked and nothing was deleted: {err}")
287            })?;
288        if let Ok(Some(other)) = storage.load(CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID) {
289            if other == b"mine" {
290                return Err(
291                    "the same key id in two key types collided: a write under one \
292                            replaced the other"
293                        .to_string(),
294                );
295            }
296        }
297        if let Ok(listed) = storage.list_keys(CONFORMANCE_KEY_TYPE) {
298            if listed.iter().any(|key| key == ISOLATION_LISTING_PROBE_ID) {
299                return Err("list_keys named a record written under another key type".to_string());
300            }
301        }
302        Ok(())
303    })();
304    for (key_type, key_id) in [
305        (CONFORMANCE_KEY_TYPE, ISOLATION_PROBE_ID),
306        (CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_PROBE_ID),
307        (CONFORMANCE_KEY_TYPE_OTHER, ISOLATION_LISTING_PROBE_ID),
308        (CONFORMANCE_KEY_TYPE_SUFFIXED, ISOLATION_LISTING_PROBE_ID),
309    ] {
310        let _ = storage.delete(key_type, key_id);
311    }
312    outcome
313}
314
315/// Remove every record the suite writes, in both key types.
316fn cleanup(storage: &dyn ProtocolStateStorage) {
317    for key_type in [CONFORMANCE_KEY_TYPE, CONFORMANCE_KEY_TYPE_OTHER] {
318        if let Ok(keys) = storage.list_keys(key_type) {
319            for key in keys {
320                let _ = storage.delete(key_type, &key);
321            }
322        }
323    }
324}
325
326/// Run the suite and render the report as JSON.
327///
328/// The shape the bindings use: one implementation of the checks, reachable
329/// from every language, instead of four that drift.
330pub fn run_json(storage: &dyn ProtocolStateStorage) -> String {
331    let report = run(storage);
332    serde_json::to_string(&report).unwrap_or_else(|err| {
333        format!(
334            "{{\"passed\":[],\"failures\":[{{\"check\":\"report_serialization\",\"detail\":\"{}\"}}]}}",
335            err.to_string().replace('"', "'")
336        )
337    })
338}