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}