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;