From 02bab283a7e72dc0ead6e4b06aadb22fa597b322 Mon Sep 17 00:00:00 2001 From: Chris van der Loo Date: Sat, 5 Sep 2026 14:09:49 -0400 Subject: [PATCH] feat: derive persisted ATProto records from Rust --- packages/protocol/Cargo.toml | 6 + packages/protocol/src/bin/export-lexicons.rs | 21 ++++ packages/protocol/src/lexicons.rs | 38 +++++++ packages/protocol/src/lib.rs | 5 + packages/protocol/src/records.rs | 110 +++++++++++++++++++ packages/protocol/src/requests.rs | 18 ++- 6 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 packages/protocol/src/bin/export-lexicons.rs create mode 100644 packages/protocol/src/lexicons.rs create mode 100644 packages/protocol/src/records.rs diff --git a/packages/protocol/Cargo.toml b/packages/protocol/Cargo.toml index 22b8336..5dd479a 100644 --- a/packages/protocol/Cargo.toml +++ b/packages/protocol/Cargo.toml @@ -5,6 +5,12 @@ edition = "2024" publish = false [dependencies] +atrium-api = { version = "=0.25.8", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["std"] } +inventory = "0.3" +jacquard-common = { version = "=0.12.2", default-features = false, features = ["std"] } +jacquard-derive = "=0.12.2" +jacquard-lexicon = { version = "=0.12.2", default-features = false } poem-openapi = "=5.1.16" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/protocol/src/bin/export-lexicons.rs b/packages/protocol/src/bin/export-lexicons.rs new file mode 100644 index 0000000..a2856d4 --- /dev/null +++ b/packages/protocol/src/bin/export-lexicons.rs @@ -0,0 +1,21 @@ +use std::{env, fs, path::PathBuf}; + +fn main() -> Result<(), Box> { + let destination = PathBuf::from( + env::args() + .nth(1) + .ok_or("Usage: export-lexicons ")?, + ); + for (relative, document) in crate_protocol::lexicons::documents()? { + let path = destination.join(relative); + fs::create_dir_all( + path.parent() + .ok_or("Schema output must have a parent directory")?, + )?; + fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&document)?), + )?; + } + Ok(()) +} diff --git a/packages/protocol/src/lexicons.rs b/packages/protocol/src/lexicons.rs new file mode 100644 index 0000000..3554b98 --- /dev/null +++ b/packages/protocol/src/lexicons.rs @@ -0,0 +1,38 @@ +use crate::{ + FILE_COLLECTION, FileRecord, MAX_FILE_BYTES, NAME_COLLECTION, NameClaim, SPACE_TYPE, + VERSION_COLLECTION, VersionRecord, +}; +use jacquard_lexicon::schema::LexiconSchema; +use serde_json::{Value, json}; + +/// Rust record definitions are canonical. This function only adds blob/space policy +/// that the upstream derive does not yet support, without repeating record fields. +pub fn documents() -> Result, serde_json::Error> { + let mut file = serde_json::to_value(FileRecord::lexicon_doc())?; + let blob = &mut file["defs"]["main"]["record"]["properties"]["blob"]; + blob["accept"] = json!(["*/*"]); + blob["maxSize"] = json!(MAX_FILE_BYTES); + Ok(vec![ + ("network/crate/file.json", file), + ( + "network/crate/fileVersion.json", + serde_json::to_value(VersionRecord::lexicon_doc())?, + ), + ( + "network/crate/fileName.json", + serde_json::to_value(NameClaim::lexicon_doc())?, + ), + ( + "network/crate/drive.json", + json!({ + "lexicon": 1, + "id": SPACE_TYPE, + "defs": {"main": { + "type": "space", + "description": "Personal Crate drive. Only its owner can read or write; clients use the shared collection protocol.", + "collections": [FILE_COLLECTION, VERSION_COLLECTION, NAME_COLLECTION] + }} + }), + ), + ]) +} diff --git a/packages/protocol/src/lib.rs b/packages/protocol/src/lib.rs index 16ac81e..1735747 100644 --- a/packages/protocol/src/lib.rs +++ b/packages/protocol/src/lib.rs @@ -1,7 +1,12 @@ +extern crate alloc; + mod files; +pub mod lexicons; +mod records; mod requests; mod session; pub use files::*; +pub use records::*; pub use requests::*; pub use session::*; diff --git a/packages/protocol/src/records.rs b/packages/protocol/src/records.rs new file mode 100644 index 0000000..56a7f63 --- /dev/null +++ b/packages/protocol/src/records.rs @@ -0,0 +1,110 @@ +pub use atrium_api::types::TypedBlobRef as BlobRef; +use jacquard_derive::LexiconSchema; +use serde::{Deserialize, Serialize}; + +pub const FILE_COLLECTION: &str = "network.crate.file"; +pub const VERSION_COLLECTION: &str = "network.crate.fileVersion"; +pub const NAME_COLLECTION: &str = "network.crate.fileName"; +pub const SPACE_TYPE: &str = "network.crate.drive"; +pub const MAX_FILE_BYTES: usize = 1_073_741_824; + +/// A mutable file head, addressed by its stable record key in a private drive space. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, LexiconSchema)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "$type", rename = "network.crate.file")] +#[lexicon(nsid = "network.crate.file", record, key = "tid")] +pub struct FileRecord { + #[lexicon(min_length = 1, max_length = 1024)] + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[lexicon(format = "record-key")] + pub parent_id: Option, + pub is_directory: bool, + #[lexicon(format = "datetime")] + pub created_at: String, + #[lexicon(format = "datetime")] + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[lexicon(format = "datetime")] + pub trashed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, +} + +impl FileRecord { + pub fn validate(&self) -> Result<(), String> { + jacquard_lexicon::schema::LexiconSchema::validate(self) + .map_err(|error| error.to_string())?; + validate_name(&self.name)?; + if let Some(parent) = &self.parent_id { + atrium_api::types::string::RecordKey::new(parent.clone()) + .map_err(|error| format!("Invalid parent record key: {error}"))?; + } + for value in [ + Some(&self.created_at), + Some(&self.updated_at), + self.trashed_at.as_ref(), + ] + .into_iter() + .flatten() + { + chrono::DateTime::parse_from_rfc3339(value) + .map_err(|error| format!("Invalid record timestamp: {error}"))?; + } + match (&self.blob, self.is_directory) { + (Some(BlobRef::Blob(blob)), false) + if blob.size <= MAX_FILE_BYTES && !blob.mime_type.is_empty() => + { + Ok(()) + } + (None, true) => Ok(()), + _ => Err("Files require a valid blob; directories cannot contain a blob.".into()), + } + } +} + +pub fn validate_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() + || matches!(name, "." | "..") + || name.contains(['/', '\\']) + || name.chars().any(char::is_control) + || name.len() > 1024 + || name.chars().count() > 255 + { + return Err( + "Name must be a nonempty filename without separators or control characters.".into(), + ); + } + Ok(()) +} + +/// Immutable snapshot created atomically before replacing a file head. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, LexiconSchema)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "$type", rename = "network.crate.fileVersion")] +#[lexicon(nsid = "network.crate.fileVersion", record, key = "any")] +pub struct VersionRecord { + #[lexicon(format = "record-key")] + pub file_id: String, + #[lexicon(format = "cid")] + pub revision: String, + #[lexicon(format = "datetime")] + pub captured_at: String, + #[lexicon(ref = "network.crate.file")] + pub record: FileRecord, +} + +/// Atomic uniqueness claim for a filename within a parent directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, LexiconSchema)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "$type", rename = "network.crate.fileName")] +#[lexicon(nsid = "network.crate.fileName", record, key = "any")] +pub struct NameClaim { + #[lexicon(format = "record-key")] + pub file_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[lexicon(format = "record-key")] + pub parent_id: Option, + #[lexicon(min_length = 1, max_length = 1024)] + pub name: String, +} diff --git a/packages/protocol/src/requests.rs b/packages/protocol/src/requests.rs index b3ea52a..b946a4b 100644 --- a/packages/protocol/src/requests.rs +++ b/packages/protocol/src/requests.rs @@ -4,7 +4,11 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Object)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[oai(rename_all = "camelCase", deny_unknown_fields, skip_serializing_if_is_none)] +#[oai( + rename_all = "camelCase", + deny_unknown_fields, + skip_serializing_if_is_none +)] pub struct CreateFile { #[oai(validator(min_length = "1", max_length = "255"))] pub name: String, @@ -16,7 +20,11 @@ pub struct CreateFile { /// Replaces both mutable metadata fields; omission of parentId means the root. #[derive(Debug, Clone, Serialize, Deserialize, Object)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[oai(rename_all = "camelCase", deny_unknown_fields, skip_serializing_if_is_none)] +#[oai( + rename_all = "camelCase", + deny_unknown_fields, + skip_serializing_if_is_none +)] pub struct UpdateFile { pub revision: String, #[oai(validator(min_length = "1", max_length = "255"))] @@ -27,7 +35,11 @@ pub struct UpdateFile { #[derive(Debug, Clone, Serialize, Deserialize, Object)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[oai(rename_all = "camelCase", deny_unknown_fields, skip_serializing_if_is_none)] +#[oai( + rename_all = "camelCase", + deny_unknown_fields, + skip_serializing_if_is_none +)] pub struct DuplicateFile { pub revision: String, #[oai(validator(min_length = "1", max_length = "255"))] -- 2.54.0