Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
9.8 kB · 235 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236//! 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<bool>) -> 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, })}