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