//! The shape rules a record meets before it is stored: a collection that //! could name a lexicon, a `$type` that agrees with it, a body the atproto //! data model can encode, and the lexicon itself when one is held. use serde_json::Value; use crate::Catalog; /// Why a record's shape is refused. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ShapeError { /// The collection is not a syntactically valid NSID. #[error("`{collection}` cannot be a collection: {source}")] MalformedCollection { /// The NSID the caller named. collection: String, /// Which of the NSID syntax rules it broke. #[source] source: didbot_lexicon::nsid_syntax::NsidError, }, /// The explicit stance was asked for and no lexicon is held for the /// collection. #[error("`{collection}` has no lexicon here, and `validate: true` requires one")] NoSchemaForCollection { /// The NSID the caller named. collection: String, }, /// The record cannot be represented in the atproto data model. #[error("record is not in the atproto data model: {source}")] UnrepresentableRecord { /// Where in the record the problem is, and what it was. #[source] source: didbot_data::DataError, }, /// The record's `$type` disagrees with the collection it was written to. #[error("record `$type` is `{declared}` but the collection is `{collection}`")] TypeMismatch { /// The `$type` carried by the record. declared: String, /// The collection the caller asked to write into. collection: String, }, /// The record's `$type` is present but is not a string. #[error("record `$type` must be a string")] TypeNotAString, /// The record does not satisfy the lexicon that defines its collection. #[error("record does not satisfy `{collection}`: `{path}` {reason}")] SchemaViolation { /// The collection whose lexicon refused the record. collection: String, /// The failing field's path within the record. path: String, /// What was wrong with it. reason: String, }, } /// The first of [`validate_with`]'s rules, on its own. /// /// Split out for the operations that name a collection and carry no record — /// a `deleteRecord`, or the delete half of an `applyWrites` batch. /// /// It asks only whether the string could name a lexicon. It deliberately does /// *not* ask whether this deployment defines one: a personal data server holds /// the account's repository, and the lexicons an account may write are the /// world's rather than this project's. /// /// ``` /// use didbot_schema::validate_collection; /// assert!(validate_collection("com.example.thing").is_ok()); /// // A lexicon this project did not author, and does not hold. /// assert!(validate_collection("app.bsky.feed.post").is_ok()); /// // Not an NSID at all, so it could never address anything. /// assert!(validate_collection("post").is_err()); /// ``` pub fn validate_collection(collection: &str) -> Result<(), ShapeError> { didbot_lexicon::nsid_syntax::validate(collection).map_err(|source| { ShapeError::MalformedCollection { collection: collection.to_owned(), source, } }) } /// Whether this server holds a lexicon that defines `collection`'s record. /// /// The question the optimistic stance turns on, and the one a write response's /// `validationStatus` reports: `valid` when this is true and the record passed, /// `unknown` when it is false and the record was stored unchecked. /// /// ``` /// use didbot_schema::schema_is_held; /// assert!(schema_is_held("bot.did.registration")); /// // A lexicon defined elsewhere, whether or not this server writes it. /// assert!(!schema_is_held("com.example.thing")); /// assert!(!schema_is_held("app.bsky.feed.post")); /// ``` pub fn schema_is_held(collection: &str) -> bool { Catalog::embedded().defines_record(collection) } /// Which of the specification's three validation stances to take for one /// write. /// /// atproto's `validate` parameter is a tri-state and each state is one of the /// stances the Lexicon specification names. Absent is the default and is the /// one this server takes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Stance { /// Validate against the schema when one is held, and store faithfully when /// it is not. `validate` absent. #[default] Optimistic, /// Every record must resolve to a schema and satisfy it. `validate: true`. Explicit, /// Do not check the record against any schema. `validate: false`. Skip, } impl Stance { /// Reads the wire's tri-state `validate` parameter. pub fn from_validate(validate: Option) -> Self { match validate { None => Self::Optimistic, Some(true) => Self::Explicit, Some(false) => Self::Skip, } } } /// The shape rules under the optimistic stance, which is the one this server /// takes when a caller says nothing. Authorship is not a property of the /// bytes, so no rule here is about *who* may write. pub fn validate_shape(collection: &str, record: &Value) -> Result<(), ShapeError> { validate_with(collection, record, Stance::Optimistic) } /// Checks a record against the collection it is being written to. /// /// Four rules, in order. /// /// 1. The collection must be a syntactically valid NSID — [`validate_collection`]. /// 2. A `$type` carried by the record must agree with it. /// 3. The record must be representable in the atproto data model. /// 4. The record must satisfy the lexicon that defines the collection, *when /// this server holds one*. /// /// The `$type` check is here because nothing else performs it. jacquard does /// not verify `$type` on deserialize, so a record claiming to be a /// record can be written into the memory collection and read back as a /// memory, with the lie preserved inside the value. That is a data corruption /// no later reader can detect, so it is refused at the only point where both /// facts are in hand. It holds for a foreign collection too: `$type` and the /// collection name the same lexicon in atproto, whoever wrote it. /// /// A record with no `$type` is accepted: the collection already says what it /// is, and atproto treats the field as redundant inside a repository. /// /// # The data model check is the one that makes storage faithful /// /// A record is stored as JSON and exported as DAG-CBOR inside a Merkle search /// tree. JSON has values the data model does not — a float, most of all — and /// under the closed collection set they could only arrive in an *undeclared* /// field, which validation deliberately carries through unchecked. Opening the /// collection set makes every field undeclared, so the check moves to the /// boundary: a record that cannot be encoded is refused on write rather than /// accepted and then breaking every export of the repository afterwards. What /// the model *can* represent survives byte for byte, which is what the /// `unknown` type exists for. /// /// # Why the schema check is conditional /// /// atproto's Lexicon specification names three stances a service may take: /// *explicit*, where every record must resolve to a known schema and satisfy /// it; *optimistic*, where a record is validated when a schema is at hand and /// accepted when it is not; and *skip*. This server takes the optimistic one, /// which is the stance a personal data server has to take, because a /// repository is the account's and the lexicons are the world's. Validation is /// explicit, and that choice held only while the collection set was closed. The /// set is open now. /// /// ``` /// use didbot_schema::validate_shape; /// use serde_json::json; /// /// // A record under a collection no schema is held for is accepted unchecked. /// let foreign = json!({"text": "reading the MST encoder"}); /// assert!(validate_shape("com.example.thing", &foreign).is_ok()); /// /// // The one schema this deployment holds is enforced. /// let bad = json!({"did": "did:web:a.example"}); /// assert!(validate_shape("bot.did.registration", &bad).is_err()); /// /// ``` pub fn validate_with(collection: &str, record: &Value, stance: Stance) -> Result<(), ShapeError> { validate_collection(collection)?; match record.get("$type") { None => {} Some(Value::String(declared)) if declared == collection => {} Some(Value::String(declared)) => { return Err(ShapeError::TypeMismatch { declared: declared.clone(), collection: collection.to_owned(), }) } Some(_) => return Err(ShapeError::TypeNotAString), } // Before the schema, and unconditionally: a record no export can encode // is one this server must not acknowledge having stored, whatever any // lexicon says about it. didbot_data::Value::object_from_json(record) .map_err(|source| ShapeError::UnrepresentableRecord { source })?; if stance == Stance::Skip { return Ok(()); } if !schema_is_held(collection) { if stance == Stance::Explicit { return Err(ShapeError::NoSchemaForCollection { collection: collection.to_owned(), }); } tracing::debug!( collection, "no lexicon held for this collection; storing the record unchecked" ); return Ok(()); } Catalog::embedded() .validate_record(collection, record) .map_err(|invalid| ShapeError::SchemaViolation { collection: collection.to_owned(), path: invalid.path, reason: invalid.reason, }) }