Skip to main content

offline_protocol_data/
lib.rs

1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3
4//! Replicated documents for the Offline Protocol SDK.
5//!
6//! Messaging is synced events; this crate is synced state. A document is a
7//! set of collections that any member of a space can edit while offline,
8//! merging deterministically when the replicas meet again.
9//!
10//! # What this crate deliberately does not do
11//!
12//! It does not persist, encrypt, or send anything. It turns edits into
13//! opaque byte deltas and turns byte deltas back into state, and that is
14//! all. Storage, sealing and delivery belong to the main `offline-protocol`
15//! crate, which already carries ten releases of reliability work this layer
16//! inherits for free.
17//!
18//! # The engine is an implementation detail
19//!
20//! A CRDT engine is embedded here and named nowhere in this crate's public
21//! API. No engine type appears in a signature, in the FFI surface, or in any
22//! binding. One leaked engine type and the engine could never be replaced
23//! without a breaking release everywhere, which is exactly the lock-in this
24//! layer exists to avoid.
25
26/// Document, collections, deltas and encoding.
27pub mod doc;
28/// Error types.
29pub mod error;
30/// Size caps and the compaction trigger.
31pub mod policy;
32/// Values stored in collections.
33pub mod value;
34
35pub use doc::{
36    BlobMeta, CatchUp, DataDoc, Delta, DocStats, ImportOutcome, RemoteImport, VersionToken,
37};
38pub use error::{DataError, DataResult};
39pub use policy::{
40    should_compact, size_verdict, SizeVerdict, COMPACT_DELTA_LOG_RATIO, COMPACT_MAX_COMMITS,
41    COMPACT_MIN_DELTA_LOG_BYTES, DOC_SIZE_WARN_BYTES, MAX_DOC_BYTES,
42};
43pub use value::DataValue;
44
45/// Longest accepted name for a space, document or collection.
46pub const MAX_NAME_LEN: usize = 128;
47
48/// Longest accepted map key.
49pub const MAX_KEY_LEN: usize = 256;
50
51/// Number of hex characters in an attachment hash, a SHA-256.
52pub const ATTACHMENT_HASH_LEN: usize = 64;
53
54/// Longest accepted attachment display name.
55///
56/// Shorter than a file system's limit on purpose. This is a label shown
57/// next to the thing, not a path anything opens, and it replicates to every
58/// member of the space whether or not they ever fetch the blob.
59pub const MAX_ATTACHMENT_NAME_LEN: usize = 256;
60
61/// Longest accepted attachment media type.
62///
63/// RFC 6838 bounds a type and a subtype at 127 characters each, so this
64/// accepts every registered name and leaves nothing to argue about.
65pub const MAX_ATTACHMENT_MIME_LEN: usize = 255;
66
67/// Largest size an attachment reference may declare.
68///
69/// Not a statement about how large a blob may be: what a transport will
70/// carry is the transfer layer's business and is far smaller than this. It
71/// is the largest value the engine can hold without changing meaning, since
72/// the engine has one integer type and it is signed. A reference declaring
73/// more is refused at the operation that writes it rather than clamped on
74/// the way in, because a clamped size is a size its writer never wrote.
75pub const MAX_ATTACHMENT_SIZE: u64 = i64::MAX as u64;
76
77/// Longest accepted single value, equal to the whole-document cap.
78///
79/// A value at or past this size can never fit inside a document that also
80/// has to hold its own framing, so accepting one only defers the refusal to
81/// a place where it is expensive: an unbounded value makes a commit whose
82/// delta record cannot be written at all, and a delta that cannot be written
83/// is re-exported larger at every retry. Refusing at the operation keeps that
84/// failure in front of the caller, while the edit is still theirs to change.
85pub const MAX_VALUE_BYTES: usize = policy::MAX_DOC_BYTES;
86
87/// Check a space, document, or collection name.
88///
89/// The charset is narrow on purpose. These names are composed into storage
90/// record keys (`{space}/{doc}`), so a name containing a separator or a
91/// path traversal would make a key that parses back into different parts
92/// than it was built from. Rejecting the character is the cheap half of
93/// that problem; the expensive half is discovering it after records exist.
94pub fn validate_name(name: &str) -> DataResult<()> {
95    if name.is_empty() {
96        return Err(DataError::InvalidName {
97            name: name.to_string(),
98            reason: "must not be empty",
99        });
100    }
101    if name.len() > MAX_NAME_LEN {
102        return Err(DataError::InvalidName {
103            name: name.to_string(),
104            reason: "longer than MAX_NAME_LEN",
105        });
106    }
107    if !name
108        .bytes()
109        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
110    {
111        return Err(DataError::InvalidName {
112            name: name.to_string(),
113            reason: "may only contain A-Z a-z 0-9 . _ -",
114        });
115    }
116    Ok(())
117}
118
119/// Check a space name.
120///
121/// Looser than [`validate_name`] by exactly one character, the colon, and
122/// the reason is that a space is named after the MLS scope it replicates
123/// in. A 1:1 space is named by a peer address; a group space is named by
124/// the group id, which is minted as `group:<uuid>`. Refusing the colon
125/// would mean either giving group spaces a second, translated name — one
126/// more mapping to keep consistent and to get wrong — or refusing group
127/// replication outright.
128///
129/// The colon is safe here in a way it would not be in a document name.
130/// Record keys are composed as `{space}/{doc}` and `{space}/{doc}/{seq}`
131/// and are parsed by stripping the space prefix and splitting the
132/// remainder on `/`, so only the separator itself can make a key parse
133/// back into different parts than it was built from, and that stays
134/// forbidden.
135///
136/// Document names deliberately keep the narrow charset: a peer names those
137/// on the wire, and this one is never wire-supplied. A 1:1 space is derived
138/// from the authenticated sender of the frame and a group space from the
139/// group whose key decrypted it, so a peer cannot choose either.
140pub fn validate_space_name(name: &str) -> DataResult<()> {
141    if name.is_empty() {
142        return Err(DataError::InvalidName {
143            name: name.to_string(),
144            reason: "must not be empty",
145        });
146    }
147    if name.len() > MAX_NAME_LEN {
148        return Err(DataError::InvalidName {
149            name: name.to_string(),
150            reason: "longer than MAX_NAME_LEN",
151        });
152    }
153    if !name
154        .bytes()
155        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b':'))
156    {
157        return Err(DataError::InvalidName {
158            name: name.to_string(),
159            reason: "may only contain A-Z a-z 0-9 . _ - :",
160        });
161    }
162    Ok(())
163}
164
165/// Check a map key.
166///
167/// Keys are looser than names because they never enter a record key: they
168/// live inside the document. The only requirements are non-empty and
169/// bounded, so a single key cannot consume a meaningful share of the
170/// document's byte budget on its own.
171pub fn validate_key(key: &str) -> DataResult<()> {
172    if key.is_empty() {
173        return Err(DataError::InvalidName {
174            name: key.to_string(),
175            reason: "must not be empty",
176        });
177    }
178    if key.len() > MAX_KEY_LEN {
179        return Err(DataError::InvalidName {
180            name: key.to_string(),
181            reason: "longer than MAX_KEY_LEN",
182        });
183    }
184    Ok(())
185}
186
187/// Check the size of a single value.
188///
189/// Bounds the one input the caller controls without limit. Note this is a
190/// per-value bound only: the whole-document cap still applies, and is what
191/// catches many small values adding up.
192pub fn validate_value(value: &DataValue) -> DataResult<()> {
193    let len = match value {
194        DataValue::Text { value } => value.len(),
195        DataValue::Bytes { value } => value.len(),
196        // An attachment is bounded by its own rules, which are tighter than
197        // the value cap and check shape rather than size: `size` describes
198        // bytes that are somewhere else, so the value cap says nothing
199        // useful about it.
200        DataValue::Attachment {
201            hash,
202            size,
203            name,
204            mime,
205        } => return validate_attachment(hash, *size, name.as_deref(), mime.as_deref()),
206        // Scalars are fixed-width and cannot breach any bound.
207        DataValue::Null
208        | DataValue::Bool { .. }
209        | DataValue::Int { .. }
210        | DataValue::Float { .. } => 0,
211    };
212    validate_value_len(len)
213}
214
215/// Check that a string is a well-formed attachment address.
216///
217/// The hash is checked for shape, not for reachability: whether anybody
218/// still holds the bytes is not knowable here, and a reference to a blob
219/// nobody has is a normal, recoverable state. What is not recoverable is a
220/// hash that is not a hash, because no fetch can ever succeed and the
221/// reference replicates to the whole space regardless.
222///
223/// Case matters. The hash is an address, and two spellings of one address
224/// are two addresses: they would fetch twice, store twice, and compare
225/// unequal while naming identical bytes. Lowercase is the canonical form,
226/// and the uppercase spelling is refused rather than folded so that a
227/// writer producing the wrong one hears about it.
228///
229/// Separate from [`validate_attachment`] because a hash also travels alone:
230/// the frames that ask for a blob and refuse one carry nothing else. Checking
231/// those through the whole-reference validator means inventing a size to
232/// satisfy it, which ties a frame check to a bound that has nothing to do
233/// with it and breaks the day that validator gains a cross-field rule.
234pub fn validate_attachment_hash(hash: &str) -> DataResult<()> {
235    if hash.len() != ATTACHMENT_HASH_LEN {
236        return Err(DataError::InvalidAttachment {
237            reason: "hash must be exactly 64 characters",
238        });
239    }
240    if !hash
241        .bytes()
242        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
243    {
244        return Err(DataError::InvalidAttachment {
245            reason: "hash must be lowercase hex",
246        });
247    }
248    Ok(())
249}
250
251/// Check an attachment reference.
252///
253/// Every bound this layer places on a reference, in one place, so that the
254/// write path and the read path cannot disagree about what a valid reference
255/// is. A reference that fails any of them reads as absent rather than as an
256/// error on the read path: see [`crate::DataDoc`].
257pub fn validate_attachment(
258    hash: &str,
259    size: u64,
260    name: Option<&str>,
261    mime: Option<&str>,
262) -> DataResult<()> {
263    validate_attachment_hash(hash)?;
264    if size == 0 {
265        return Err(DataError::InvalidAttachment {
266            reason: "size must not be zero",
267        });
268    }
269    // The upper bound is structural rather than a policy about how large a
270    // blob may be: the engine has one integer type and it is signed, so a
271    // size past this cannot be stored without changing it. Refused here so
272    // that the conversion further down is lossless by construction. Left to
273    // the conversion, the same value would be silently clamped and a replica
274    // would read back a size its writer never wrote.
275    if size > MAX_ATTACHMENT_SIZE {
276        return Err(DataError::InvalidAttachment {
277            reason: "size is larger than MAX_ATTACHMENT_SIZE",
278        });
279    }
280    if name.is_some_and(|name| name.len() > MAX_ATTACHMENT_NAME_LEN) {
281        return Err(DataError::InvalidAttachment {
282            reason: "name is longer than MAX_ATTACHMENT_NAME_LEN",
283        });
284    }
285    if mime.is_some_and(|mime| mime.len() > MAX_ATTACHMENT_MIME_LEN) {
286        return Err(DataError::InvalidAttachment {
287            reason: "mime is longer than MAX_ATTACHMENT_MIME_LEN",
288        });
289    }
290    Ok(())
291}
292
293/// Check a value length against [`MAX_VALUE_BYTES`].
294///
295/// Split out for text insertions, which carry a `&str` rather than a
296/// [`DataValue`].
297pub fn validate_value_len(len: usize) -> DataResult<()> {
298    if len > MAX_VALUE_BYTES {
299        return Err(DataError::ValueTooLarge {
300            actual: len,
301            limit: MAX_VALUE_BYTES,
302        });
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests;