Skip to main content

didbot_data/
value.rs

1//! The atproto data model: the values a record is made of.
2//!
3//! atproto's data model is the IPLD data model minus everything that would
4//! make a value ambiguous. There are no floats, no undefined, no maps keyed by
5//! anything but a string, and two kinds of value that JSON cannot hold at all
6//! — byte strings and links — which the JSON representation carries as
7//! one-key objects, `$bytes` and `$link`.
8//!
9//! [`Value`] is that model, and the point of parsing into it rather than
10//! working from `serde_json::Value` directly is that it can only hold values
11//! that have an encoding. A `f64` that is not an integer has nowhere to live
12//! here; a `$link` that is not a CID cannot be built. Everything that could
13//! fail fails in [`Value::from_json`], which is why
14//! [`encode`](crate::dag_cbor::encode) cannot.
15//!
16//! # What is checked, and why more than a shape
17//!
18//! Three of the model's constructs are *tagged*: an object whose keys are
19//! exactly `$link`, exactly `$bytes`, or whose `$type` is `blob`. Each is
20//! checked whole rather than field by field, including refusing keys beyond
21//! the ones the construct defines. An object carrying `$link` alongside
22//! anything else is not a link with an extra field, it is a value with two
23//! readings — a link to one implementation and a plain object to another —
24//! and the upstream vectors refuse it for that reason.
25//!
26//! This is deliberately the opposite posture to record validation,
27//! which preserves fields it does not recognise so that a newer client can
28//! write through an older server. The two do not conflict: a lexicon's
29//! properties are an open set that grows, and these three constructs are
30//! closed shapes defined by the protocol itself. A `$link` with a second key
31//! is not a newer kind of link.
32
33use std::collections::BTreeMap;
34
35use crate::cid::{Cid, CidError};
36use crate::encoding::{base64_decode, base64_encode};
37
38/// The key naming a link in the JSON representation.
39pub const LINK_KEY: &str = "$link";
40
41/// The key naming a byte string in the JSON representation.
42pub const BYTES_KEY: &str = "$bytes";
43
44/// The key naming a value's schema type.
45pub const TYPE_KEY: &str = "$type";
46
47/// The `$type` that marks a blob rather than a record.
48pub const BLOB_TYPE: &str = "blob";
49
50/// A value in the atproto data model.
51///
52/// There is no float variant, and that absence is the point rather than an
53/// omission. A float has several encodings of the same number and no exact
54/// equality, so a record containing one has no single hash; atproto removed
55/// them from the model outright. A JSON number that happens to be written
56/// `123.0` is an integer and parses as one, and one that is not is refused.
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Value {
59    /// JSON `null`.
60    Null,
61    /// A boolean.
62    Bool(bool),
63    /// A signed 64-bit integer, which is the only number the model has.
64    Integer(i64),
65    /// A UTF-8 string.
66    String(String),
67    /// A byte string, written `{"$bytes": "..."}` in JSON.
68    Bytes(Vec<u8>),
69    /// A link to another block, written `{"$link": "..."}` in JSON.
70    Link(Cid),
71    /// An ordered list.
72    List(Vec<Value>),
73    /// A map with string keys. Insertion order is not kept, because the
74    /// encoding defines its own order and nothing else may depend on one.
75    Map(BTreeMap<String, Value>),
76}
77
78/// Where in a value a problem was found, and what it was.
79///
80/// The path is carried because these values nest arbitrarily and a record is
81/// rejected wholesale: "a float" is a much slower thing to act on than
82/// "`post.embed.images[1].aspectRatio.width` is a float".
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DataError {
85    /// A dotted and bracketed path from the root of the value.
86    pub path: String,
87    /// What was wrong there.
88    pub kind: ErrorKind,
89}
90
91/// What was wrong at one point in a value.
92#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
93pub enum ErrorKind {
94    /// The top level of a record was not a map.
95    #[error("the data model requires an object here, not {0}")]
96    NotAnObject(&'static str),
97    /// A number with a fractional part, or one no `i64` holds.
98    #[error("{0} is not an integer the data model can hold")]
99    NotAnInteger(String),
100    /// `$type` was present but was not a non-empty string.
101    #[error("$type must be a non-empty string")]
102    BadType,
103    /// A `$link` or `$bytes` object with more than the one key it defines.
104    #[error("{0} must be the only key in its object")]
105    TaggedNotAlone(&'static str),
106    /// The value under `$link` or `$bytes` was not a string.
107    #[error("{0} must hold a string")]
108    TaggedNotAString(&'static str),
109    /// The string under `$link` was not a CID.
110    #[error("$link is not a usable cid: {0}")]
111    Link(#[from] CidError),
112    /// The string under `$bytes` was not base64.
113    #[error("$bytes is not valid base64: {0}")]
114    Bytes(#[from] base64::DecodeError),
115    /// A blob was missing a field, carried a spare one, or had one of the
116    /// wrong type.
117    #[error("{0}")]
118    Blob(String),
119}
120
121impl std::fmt::Display for DataError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        let where_ = if self.path.is_empty() {
124            "the top level"
125        } else {
126            &self.path
127        };
128        write!(f, "at {where_}: {}", self.kind)
129    }
130}
131
132impl std::error::Error for DataError {
133    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
134        Some(&self.kind)
135    }
136}
137
138impl DataError {
139    /// Builds an error at `path`.
140    fn at(path: &str, kind: impl Into<ErrorKind>) -> Self {
141        Self {
142            path: path.to_owned(),
143            kind: kind.into(),
144        }
145    }
146}
147
148/// Extends a path with a map key.
149fn field(path: &str, key: &str) -> String {
150    if path.is_empty() {
151        key.to_owned()
152    } else {
153        format!("{path}.{key}")
154    }
155}
156
157/// Extends a path with a list index.
158fn index(path: &str, position: usize) -> String {
159    format!("{path}[{position}]")
160}
161
162/// Names a JSON value's type, for the top-level error message.
163fn json_type(json: &serde_json::Value) -> &'static str {
164    match json {
165        serde_json::Value::Null => "null",
166        serde_json::Value::Bool(_) => "a boolean",
167        serde_json::Value::Number(_) => "a number",
168        serde_json::Value::String(_) => "a string",
169        serde_json::Value::Array(_) => "an array",
170        serde_json::Value::Object(_) => "an object",
171    }
172}
173
174impl Value {
175    /// Parses one JSON value into the data model.
176    ///
177    /// ```
178    /// use didbot_data::Value;
179    /// let json = serde_json::json!({"a": 1, "b": [true, null]});
180    /// assert!(Value::from_json(&json).is_ok());
181    /// assert!(Value::from_json(&serde_json::json!(1.5)).is_err());
182    /// ```
183    pub fn from_json(json: &serde_json::Value) -> Result<Self, DataError> {
184        Self::parse(json, "")
185    }
186
187    /// Parses a JSON value that must be an object, which is what a record is.
188    ///
189    /// The data model permits any value inside a record, but the record
190    /// itself — and every block a repository stores — is a map. Upstream's
191    /// first invalid vector is the bare string `"blah"`, so this is a rule
192    /// with a test rather than a tidiness.
193    pub fn object_from_json(json: &serde_json::Value) -> Result<Self, DataError> {
194        if !json.is_object() {
195            return Err(DataError::at("", ErrorKind::NotAnObject(json_type(json))));
196        }
197        Self::parse(json, "")
198    }
199
200    /// Parses `json` at `path`.
201    fn parse(json: &serde_json::Value, path: &str) -> Result<Self, DataError> {
202        match json {
203            serde_json::Value::Null => Ok(Self::Null),
204            serde_json::Value::Bool(value) => Ok(Self::Bool(*value)),
205            serde_json::Value::Number(number) => parse_number(number, path),
206            serde_json::Value::String(text) => Ok(Self::String(text.clone())),
207            serde_json::Value::Array(items) => items
208                .iter()
209                .enumerate()
210                .map(|(position, item)| Self::parse(item, &index(path, position)))
211                .collect::<Result<Vec<_>, _>>()
212                .map(Self::List),
213            serde_json::Value::Object(map) => parse_object(map, path),
214        }
215    }
216
217    /// Renders back to the JSON representation.
218    ///
219    /// Round-tripping is not free: a JSON document that wrote `123.0` comes
220    /// back as `123`, because the data model holds one integer and not two
221    /// spellings of it. Everything the model *can* distinguish survives.
222    pub fn to_json(&self) -> serde_json::Value {
223        match self {
224            Self::Null => serde_json::Value::Null,
225            Self::Bool(value) => serde_json::Value::Bool(*value),
226            Self::Integer(value) => serde_json::Value::Number((*value).into()),
227            Self::String(text) => serde_json::Value::String(text.clone()),
228            Self::Bytes(bytes) => {
229                serde_json::json!({ BYTES_KEY: base64_encode(bytes) })
230            }
231            Self::Link(cid) => serde_json::json!({ LINK_KEY: cid.to_string() }),
232            Self::List(items) => {
233                serde_json::Value::Array(items.iter().map(Self::to_json).collect())
234            }
235            Self::Map(entries) => serde_json::Value::Object(
236                entries
237                    .iter()
238                    .map(|(key, value)| (key.clone(), value.to_json()))
239                    .collect(),
240            ),
241        }
242    }
243}
244
245/// Parses a JSON number, which is an integer here or it is nothing.
246fn parse_number(number: &serde_json::Number, path: &str) -> Result<Value, DataError> {
247    if let Some(value) = number.as_i64() {
248        return Ok(Value::Integer(value));
249    }
250    // A float is accepted only when it is exactly an integer that fits. This
251    // is not leniency about floats: JSON has one number type, so a serializer
252    // that wrote an integer through a double gives `123.0` for a value the
253    // data model does hold. `123.456` does not survive, and neither does an
254    // integer past `i64`.
255    if let Some(value) = number.as_f64() {
256        if value.fract() == 0.0 && value >= -(2f64.powi(63)) && value < 2f64.powi(63) {
257            return Ok(Value::Integer(value as i64));
258        }
259    }
260    Err(DataError::at(
261        path,
262        ErrorKind::NotAnInteger(number.to_string()),
263    ))
264}
265
266/// Parses a JSON object: a link, a byte string, a blob, or a plain map.
267fn parse_object(
268    map: &serde_json::Map<String, serde_json::Value>,
269    path: &str,
270) -> Result<Value, DataError> {
271    if let Some(value) = map.get(LINK_KEY) {
272        return parse_link(map, value, path);
273    }
274    if let Some(value) = map.get(BYTES_KEY) {
275        return parse_bytes(map, value, path);
276    }
277    if let Some(declared) = map.get(TYPE_KEY) {
278        match declared.as_str() {
279            Some(BLOB_TYPE) => return parse_blob(map, path),
280            Some(name) if !name.is_empty() => {}
281            _ => return Err(DataError::at(path, ErrorKind::BadType)),
282        }
283    }
284    let mut entries = BTreeMap::new();
285    for (key, value) in map {
286        entries.insert(key.clone(), Value::parse(value, &field(path, key))?);
287    }
288    Ok(Value::Map(entries))
289}
290
291/// Parses `{"$link": "..."}`, and nothing that merely contains a `$link`.
292fn parse_link(
293    map: &serde_json::Map<String, serde_json::Value>,
294    value: &serde_json::Value,
295    path: &str,
296) -> Result<Value, DataError> {
297    if map.len() != 1 {
298        return Err(DataError::at(path, ErrorKind::TaggedNotAlone(LINK_KEY)));
299    }
300    let text = value
301        .as_str()
302        .ok_or_else(|| DataError::at(path, ErrorKind::TaggedNotAString(LINK_KEY)))?;
303    Cid::parse(text)
304        .map(Value::Link)
305        .map_err(|err| DataError::at(path, err))
306}
307
308/// Parses `{"$bytes": "..."}`.
309fn parse_bytes(
310    map: &serde_json::Map<String, serde_json::Value>,
311    value: &serde_json::Value,
312    path: &str,
313) -> Result<Value, DataError> {
314    if map.len() != 1 {
315        return Err(DataError::at(path, ErrorKind::TaggedNotAlone(BYTES_KEY)));
316    }
317    let text = value
318        .as_str()
319        .ok_or_else(|| DataError::at(path, ErrorKind::TaggedNotAString(BYTES_KEY)))?;
320    base64_decode(text)
321        .map(Value::Bytes)
322        .map_err(|err| DataError::at(path, err))
323}
324
325/// The four keys a blob has, and no others.
326const BLOB_KEYS: [&str; 4] = [TYPE_KEY, "ref", "mimeType", "size"];
327
328/// Parses `{"$type": "blob", "ref": ..., "mimeType": ..., "size": ...}`.
329///
330/// A blob is a plain map once it is checked — there is no `Value::Blob`,
331/// because a blob encodes exactly as the map it looks like. What this refuses
332/// is a *malformed* one, and the reason to refuse it here rather than during
333/// lexicon validation is that a blob reference is the protocol's own shape:
334/// every lexicon that declares a blob means this one, and a stored blob whose
335/// `size` is the string `"10000"` is unusable to every reader regardless of
336/// which lexicon declared it.
337fn parse_blob(
338    map: &serde_json::Map<String, serde_json::Value>,
339    path: &str,
340) -> Result<Value, DataError> {
341    let blob = |reason: String| DataError::at(path, ErrorKind::Blob(reason));
342    for key in map.keys() {
343        if !BLOB_KEYS.contains(&key.as_str()) {
344            return Err(blob(format!("a blob has no {key:?} field")));
345        }
346    }
347    for key in BLOB_KEYS {
348        if !map.contains_key(key) {
349            return Err(blob(format!("a blob needs a {key:?} field")));
350        }
351    }
352    let is_a_link = || blob("a blob's ref must be a $link".to_owned());
353    let referenced = map["ref"].as_object().ok_or_else(is_a_link)?;
354    let link = referenced.get(LINK_KEY).ok_or_else(is_a_link)?;
355    let reference = parse_link(referenced, link, &field(path, "ref"))?;
356    if !map["mimeType"].is_string() {
357        return Err(blob("a blob's mimeType must be a string".to_owned()));
358    }
359    let size = parse_number(
360        map["size"]
361            .as_number()
362            .ok_or_else(|| blob("a blob's size must be an integer".to_owned()))?,
363        &field(path, "size"),
364    )?;
365    if !matches!(size, Value::Integer(bytes) if bytes >= 0) {
366        return Err(blob("a blob's size must be an integer".to_owned()));
367    }
368
369    let mut entries = BTreeMap::new();
370    entries.insert(TYPE_KEY.to_owned(), Value::String(BLOB_TYPE.to_owned()));
371    entries.insert("ref".to_owned(), reference);
372    entries.insert(
373        "mimeType".to_owned(),
374        Value::String(map["mimeType"].as_str().unwrap_or_default().to_owned()),
375    );
376    entries.insert("size".to_owned(), size);
377    Ok(Value::Map(entries))
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use serde_json::json;
384
385    fn parse(json: serde_json::Value) -> Result<Value, DataError> {
386        Value::object_from_json(&json)
387    }
388
389    #[test]
390    fn an_integer_written_as_a_float_is_an_integer() {
391        assert_eq!(
392            parse(json!({"a": 123.0})),
393            parse(json!({"a": 123})),
394            "the data model has one number, so these are one value"
395        );
396    }
397
398    #[test]
399    fn a_number_past_i64_has_nowhere_to_go() {
400        assert!(Value::from_json(&json!(1e300)).is_err());
401    }
402
403    #[test]
404    fn the_error_names_where_it_was() {
405        let err = parse(json!({"a": {"b": [1, 1.5]}})).unwrap_err();
406        assert_eq!(err.path, "a.b[1]");
407    }
408
409    #[test]
410    fn a_link_beside_another_key_is_refused() {
411        let err = parse(json!({
412            "l": {LINK_KEY: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", "x": 1}
413        }))
414        .unwrap_err();
415        assert_eq!(err.kind, ErrorKind::TaggedNotAlone(LINK_KEY));
416    }
417
418    #[test]
419    fn a_blob_keeps_only_the_four_fields_it_has() {
420        let blob = json!({
421            "b": {
422                TYPE_KEY: "blob",
423                "ref": {LINK_KEY: "bafkreiccldh766hwcnuxnf2wh6jgzepf2nlu2lvcllt63eww5p6chi4ity"},
424                "mimeType": "image/jpeg",
425                "size": 10000,
426            }
427        });
428        let parsed = parse(blob.clone()).expect("a well-formed blob");
429        assert_eq!(parsed.to_json(), blob);
430    }
431
432    #[test]
433    fn a_negative_blob_size_is_not_a_size() {
434        let err = parse(json!({
435            "b": {
436                TYPE_KEY: "blob",
437                "ref": {LINK_KEY: "bafkreiccldh766hwcnuxnf2wh6jgzepf2nlu2lvcllt63eww5p6chi4ity"},
438                "mimeType": "image/jpeg",
439                "size": -1,
440            }
441        }))
442        .unwrap_err();
443        assert!(matches!(err.kind, ErrorKind::Blob(_)));
444    }
445
446    #[test]
447    fn a_type_that_is_not_blob_is_left_alone_beyond_being_a_string() {
448        let record = json!({TYPE_KEY: "com.example.blah", "a": 1, "b": "blah"});
449        assert_eq!(parse(record.clone()).unwrap().to_json(), record);
450    }
451
452    #[test]
453    fn bytes_round_trip_through_the_json_representation() {
454        let value = json!({"b": {BYTES_KEY: "nFERjvLLiw9qm45JrqH9QTzyC2Lu1Xb4ne6+sBrCzI0"}});
455        assert_eq!(parse(value.clone()).unwrap().to_json(), value);
456    }
457}