Skip to main content

didbot_schema/
shape.rs

1//! The shape rules a record meets before it is stored: a collection that
2//! could name a lexicon, a `$type` that agrees with it, a body the atproto
3//! data model can encode, and the lexicon itself when one is held.
4
5use serde_json::Value;
6
7use crate::Catalog;
8
9/// Why a record's shape is refused.
10#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
11pub enum ShapeError {
12    /// The collection is not a syntactically valid NSID.
13    #[error("`{collection}` cannot be a collection: {source}")]
14    MalformedCollection {
15        /// The NSID the caller named.
16        collection: String,
17        /// Which of the NSID syntax rules it broke.
18        #[source]
19        source: didbot_lexicon::nsid_syntax::NsidError,
20    },
21    /// The explicit stance was asked for and no lexicon is held for the
22    /// collection.
23    #[error("`{collection}` has no lexicon here, and `validate: true` requires one")]
24    NoSchemaForCollection {
25        /// The NSID the caller named.
26        collection: String,
27    },
28    /// The record cannot be represented in the atproto data model.
29    #[error("record is not in the atproto data model: {source}")]
30    UnrepresentableRecord {
31        /// Where in the record the problem is, and what it was.
32        #[source]
33        source: didbot_data::DataError,
34    },
35    /// The record's `$type` disagrees with the collection it was written to.
36    #[error("record `$type` is `{declared}` but the collection is `{collection}`")]
37    TypeMismatch {
38        /// The `$type` carried by the record.
39        declared: String,
40        /// The collection the caller asked to write into.
41        collection: String,
42    },
43    /// The record's `$type` is present but is not a string.
44    #[error("record `$type` must be a string")]
45    TypeNotAString,
46    /// The record does not satisfy the lexicon that defines its collection.
47    #[error("record does not satisfy `{collection}`: `{path}` {reason}")]
48    SchemaViolation {
49        /// The collection whose lexicon refused the record.
50        collection: String,
51        /// The failing field's path within the record.
52        path: String,
53        /// What was wrong with it.
54        reason: String,
55    },
56}
57
58/// The first of [`validate_with`]'s rules, on its own.
59///
60/// Split out for the operations that name a collection and carry no record —
61/// a `deleteRecord`, or the delete half of an `applyWrites` batch.
62///
63/// It asks only whether the string could name a lexicon. It deliberately does
64/// *not* ask whether this deployment defines one: a personal data server holds
65/// the account's repository, and the lexicons an account may write are the
66/// world's rather than this project's.
67///
68/// ```
69/// use didbot_schema::validate_collection;
70/// assert!(validate_collection("com.example.thing").is_ok());
71/// // A lexicon this project did not author, and does not hold.
72/// assert!(validate_collection("app.bsky.feed.post").is_ok());
73/// // Not an NSID at all, so it could never address anything.
74/// assert!(validate_collection("post").is_err());
75/// ```
76pub fn validate_collection(collection: &str) -> Result<(), ShapeError> {
77    didbot_lexicon::nsid_syntax::validate(collection).map_err(|source| {
78        ShapeError::MalformedCollection {
79            collection: collection.to_owned(),
80            source,
81        }
82    })
83}
84
85/// Whether this server holds a lexicon that defines `collection`'s record.
86///
87/// The question the optimistic stance turns on, and the one a write response's
88/// `validationStatus` reports: `valid` when this is true and the record passed,
89/// `unknown` when it is false and the record was stored unchecked.
90///
91/// ```
92/// use didbot_schema::schema_is_held;
93/// assert!(schema_is_held("bot.did.registration"));
94/// // A lexicon defined elsewhere, whether or not this server writes it.
95/// assert!(!schema_is_held("com.example.thing"));
96/// assert!(!schema_is_held("app.bsky.feed.post"));
97/// ```
98pub fn schema_is_held(collection: &str) -> bool {
99    Catalog::embedded().defines_record(collection)
100}
101
102/// Which of the specification's three validation stances to take for one
103/// write.
104///
105/// atproto's `validate` parameter is a tri-state and each state is one of the
106/// stances the Lexicon specification names. Absent is the default and is the
107/// one this server takes.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum Stance {
110    /// Validate against the schema when one is held, and store faithfully when
111    /// it is not. `validate` absent.
112    #[default]
113    Optimistic,
114    /// Every record must resolve to a schema and satisfy it. `validate: true`.
115    Explicit,
116    /// Do not check the record against any schema. `validate: false`.
117    Skip,
118}
119
120impl Stance {
121    /// Reads the wire's tri-state `validate` parameter.
122    pub fn from_validate(validate: Option<bool>) -> Self {
123        match validate {
124            None => Self::Optimistic,
125            Some(true) => Self::Explicit,
126            Some(false) => Self::Skip,
127        }
128    }
129}
130
131/// The shape rules under the optimistic stance, which is the one this server
132/// takes when a caller says nothing. Authorship is not a property of the
133/// bytes, so no rule here is about *who* may write.
134pub fn validate_shape(collection: &str, record: &Value) -> Result<(), ShapeError> {
135    validate_with(collection, record, Stance::Optimistic)
136}
137
138/// Checks a record against the collection it is being written to.
139///
140/// Four rules, in order.
141///
142/// 1. The collection must be a syntactically valid NSID — [`validate_collection`].
143/// 2. A `$type` carried by the record must agree with it.
144/// 3. The record must be representable in the atproto data model.
145/// 4. The record must satisfy the lexicon that defines the collection, *when
146///    this server holds one*.
147///
148/// The `$type` check is here because nothing else performs it. jacquard does
149/// not verify `$type` on deserialize, so a record claiming to be a
150/// record can be written into the memory collection and read back as a
151/// memory, with the lie preserved inside the value. That is a data corruption
152/// no later reader can detect, so it is refused at the only point where both
153/// facts are in hand. It holds for a foreign collection too: `$type` and the
154/// collection name the same lexicon in atproto, whoever wrote it.
155///
156/// A record with no `$type` is accepted: the collection already says what it
157/// is, and atproto treats the field as redundant inside a repository.
158///
159/// # The data model check is the one that makes storage faithful
160///
161/// A record is stored as JSON and exported as DAG-CBOR inside a Merkle search
162/// tree. JSON has values the data model does not — a float, most of all — and
163/// under the closed collection set they could only arrive in an *undeclared*
164/// field, which validation deliberately carries through unchecked. Opening the
165/// collection set makes every field undeclared, so the check moves to the
166/// boundary: a record that cannot be encoded is refused on write rather than
167/// accepted and then breaking every export of the repository afterwards. What
168/// the model *can* represent survives byte for byte, which is what the
169/// `unknown` type exists for.
170///
171/// # Why the schema check is conditional
172///
173/// atproto's Lexicon specification names three stances a service may take:
174/// *explicit*, where every record must resolve to a known schema and satisfy
175/// it; *optimistic*, where a record is validated when a schema is at hand and
176/// accepted when it is not; and *skip*. This server takes the optimistic one,
177/// which is the stance a personal data server has to take, because a
178/// repository is the account's and the lexicons are the world's. Validation is
179/// explicit, and that choice held only while the collection set was closed. The
180/// set is open now.
181///
182/// ```
183/// use didbot_schema::validate_shape;
184/// use serde_json::json;
185///
186/// // A record under a collection no schema is held for is accepted unchecked.
187/// let foreign = json!({"text": "reading the MST encoder"});
188/// assert!(validate_shape("com.example.thing", &foreign).is_ok());
189///
190/// // The one schema this deployment holds is enforced.
191/// let bad = json!({"did": "did:web:a.example"});
192/// assert!(validate_shape("bot.did.registration", &bad).is_err());
193///
194/// ```
195pub fn validate_with(collection: &str, record: &Value, stance: Stance) -> Result<(), ShapeError> {
196    validate_collection(collection)?;
197    match record.get("$type") {
198        None => {}
199        Some(Value::String(declared)) if declared == collection => {}
200        Some(Value::String(declared)) => {
201            return Err(ShapeError::TypeMismatch {
202                declared: declared.clone(),
203                collection: collection.to_owned(),
204            })
205        }
206        Some(_) => return Err(ShapeError::TypeNotAString),
207    }
208    // Before the schema, and unconditionally: a record no export can encode
209    // is one this server must not acknowledge having stored, whatever any
210    // lexicon says about it.
211    didbot_data::Value::object_from_json(record)
212        .map_err(|source| ShapeError::UnrepresentableRecord { source })?;
213    if stance == Stance::Skip {
214        return Ok(());
215    }
216    if !schema_is_held(collection) {
217        if stance == Stance::Explicit {
218            return Err(ShapeError::NoSchemaForCollection {
219                collection: collection.to_owned(),
220            });
221        }
222        tracing::debug!(
223            collection,
224            "no lexicon held for this collection; storing the record unchecked"
225        );
226        return Ok(());
227    }
228    Catalog::embedded()
229        .validate_record(collection, record)
230        .map_err(|invalid| ShapeError::SchemaViolation {
231            collection: collection.to_owned(),
232            path: invalid.path,
233            reason: invalid.reason,
234        })
235}