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