From 1d282258990fbc67448099001faabcd2e418b43a Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 27 Sep 2025 02:51:55 +0000 Subject: [PATCH] more string types, blobrefs opting to just...not be compatible with the old untyped blobrefs to simplify. haven't seen those in the wild ever afaik. might swap to a custom Serialize/Deserialize impl to get that capability without a bunch of extra nesting. --- Cargo.lock | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/jacquard-common/Cargo.toml | 4 +++- crates/jacquard-common/src/blob.rs | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- crates/jacquard-common/src/cid.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- crates/jacquard-common/src/cowstr.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- crates/jacquard-common/src/did.rs | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- crates/jacquard-common/src/lib.rs | 6 ++++++ 7 file(s) changed, 576 insertion(s)(+), 15 deletion(s)(-) diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,8 @@ "core2", "multibase", "multihash", + "serde", + "serde_bytes", "unsigned-varint", ] @@ -220,10 +222,12 @@ dependencies = [ "cid", "compact_str", + "miette", "multibase", "multihash", "regex", "serde", + "thiserror", ] [[package]] @@ -231,6 +235,28 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "multibase" @@ -250,6 +276,7 @@ checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" dependencies = [ "core2", + "serde", "unsigned-varint", ] @@ -329,6 +356,15 @@ ] [[package]] +name = "serde_bytes" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +dependencies = [ + "serde", +] + +[[package]] name = "serde_core" version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -372,10 +408,36 @@ ] [[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "unicode-ident" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unsigned-varint" diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -6,9 +6,11 @@ description.workspace = true [dependencies] -cid = "0.11.1" +cid = { version = "0.11.1", features = ["serde", "std"] } compact_str = "0.9.0" +miette = "7.6.0" multibase = "0.9.1" multihash = "0.19.3" regex = "1.11.3" serde = { version = "1.0.227", features = ["derive"] } +thiserror = "2.0.16" diff --git a/crates/jacquard-common/src/blob.rs b/crates/jacquard-common/src/blob.rs --- a/crates/jacquard-common/src/blob.rs +++ b/crates/jacquard-common/src/blob.rs @@ -1,5 +1,132 @@ -use crate::CowStr; +use crate::{CowStr, cid::Cid}; +use compact_str::ToCompactString; +#[allow(unused)] +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; +#[allow(unused)] +use std::{ + borrow::Cow, + fmt, + hash::{Hash, Hasher}, + ops::Deref, + str::FromStr, +}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Blob<'b> { + pub r#ref: Cid<'b>, + #[serde(borrow)] + pub mime_type: MimeType<'b>, + pub size: usize, +} + +impl<'r> BlobRef<'r> { + pub fn blob(&self) -> &Blob<'r> { + match self { + BlobRef::Blob(blob) => blob, + } + } +} + +/// Current, typed blob reference. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(tag = "$type", rename_all = "lowercase")] +pub enum BlobRef<'r> { + #[serde(borrow)] + Blob(Blob<'r>), +} /// Wrapper for file type -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] pub struct MimeType<'m>(pub CowStr<'m>); + +impl<'m> MimeType<'m> { + /// Fallible constructor, validates, borrows from input + pub fn new(mime_type: &'m str) -> Result, &'static str> { + Ok(Self(CowStr::Borrowed(mime_type))) + } + + /// Fallible constructor from an existing CowStr, borrows + pub fn from_cowstr(mime_type: CowStr<'m>) -> Result, &'static str> { + Ok(Self(mime_type)) + } + + /// Infallible constructor + pub fn raw(mime_type: &'m str) -> Self { + Self(CowStr::Borrowed(mime_type)) + } + + pub fn as_str(&self) -> &str { + { + let this = &self.0; + this + } + } +} + +impl FromStr for MimeType<'_> { + type Err = &'static str; + + /// Has to take ownership due to the lifetime constraints of the FromStr trait. + fn from_str(s: &str) -> Result { + Self::from_cowstr(CowStr::Owned(s.to_compact_string())) + } +} + +impl<'de, 'b> Deserialize<'de> for MimeType<'b> +where + 'de: 'b, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Deserialize::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + +impl fmt::Display for MimeType<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'m> From> for String { + fn from(value: MimeType<'m>) -> Self { + value.0.to_string() + } +} + +impl<'m> From> for CowStr<'m> { + fn from(value: MimeType<'m>) -> Self { + value.0 + } +} + +impl From for MimeType<'static> { + fn from(value: String) -> Self { + Self(CowStr::Owned(value.to_compact_string())) + } +} + +impl<'m> From> for MimeType<'m> { + fn from(value: CowStr<'m>) -> Self { + Self(value) + } +} + +impl AsRef for MimeType<'_> { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for MimeType<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} diff --git a/crates/jacquard-common/src/cid.rs b/crates/jacquard-common/src/cid.rs --- a/crates/jacquard-common/src/cid.rs +++ b/crates/jacquard-common/src/cid.rs @@ -1,7 +1,11 @@ -use serde::{Deserialize, Serialize}; -use std::str:: +use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr}; + +use compact_str::ToCompactString; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor}; pub use cid::Cid as IpldCid; + +use crate::CowStr; /// raw pub const ATP_CID_CODEC: u64 = 0x55; @@ -10,11 +14,182 @@ pub const ATP_CID_HASH: u64 = 0x12; /// base 32 -pub const ATP_CID_BASE: multibase::Base = multibase::Base::Base32; +pub const ATP_CID_BASE: multibase::Base = multibase::Base::Base32Lower; -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(untagged)] -pub enum Cid { - Cid(IpldCid), - CidStr(CowStr<'static>), +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Either the string form of a cid or the ipld form +/// For the IPLD form we also cache the string representation for later use. +/// +/// Default on deserialization matches the format (if we get bytes, we try to decode) +pub enum Cid<'c> { + Ipld { cid: IpldCid, s: CowStr<'c> }, + Str(CowStr<'c>), +} + +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum Error { + #[error("Invalid IPLD CID {:?}", 0)] + Ipld(#[from] cid::Error), + #[error("{:?}", 0)] + Utf8(#[from] std::str::Utf8Error), +} + +impl<'c> Cid<'c> { + pub fn new(cid: &'c [u8]) -> Result { + if let Ok(cid) = IpldCid::try_from(cid.as_ref()) { + Ok(Self::ipld(cid)) + } else { + let cid_str = CowStr::from_utf8(cid)?; + Ok(Self::Str(cid_str)) + } + } + pub fn ipld(cid: IpldCid) -> Self { + let s = CowStr::Owned( + cid.to_string_of_base(ATP_CID_BASE) + .unwrap_or_default() + .to_compact_string(), + ); + Self::Ipld { cid, s } + } + + pub fn str(cid: &'c str) -> Self { + Self::Str(CowStr::Borrowed(cid)) + } + + pub fn cow_str(cid: CowStr<'c>) -> Self { + Self::Str(cid) + } + + pub fn to_ipld(&self) -> Result { + match self { + Cid::Ipld { cid, s: _ } => Ok(cid.clone()), + Cid::Str(cow_str) => IpldCid::try_from(cow_str.as_ref()), + } + } + + pub fn as_str(&self) -> &str { + match self { + Cid::Ipld { cid: _, s } => s.as_ref(), + Cid::Str(cow_str) => cow_str.as_ref(), + } + } +} + +impl std::fmt::Display for Cid<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Cid::Ipld { cid: _, s } => f.write_str(&s), + Cid::Str(cow_str) => f.write_str(&cow_str), + } + } +} + +impl FromStr for Cid<'_> { + type Err = Infallible; + + /// Has to take ownership due to the lifetime constraints of the FromStr trait. + fn from_str(s: &str) -> Result { + Ok(Cid::Str(CowStr::Owned(s.to_compact_string()))) + } +} + +impl Serialize for Cid<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Cid::Ipld { cid, s: _ } => cid.serialize(serializer), + Cid::Str(cow_str) => cow_str.serialize(serializer), + } + } +} + +// TODO: take another look at this, see if we can do more borrowed and such +impl<'de> Deserialize<'de> for Cid<'_> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StringOrBytes(PhantomData T>); + + impl<'de, T> Visitor<'de> for StringOrBytes + where + T: Deserialize<'de> + FromStr + From, + { + type Value = T; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("either valid IPLD CID bytes or a str") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(FromStr::from_str(v).unwrap()) + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: serde::de::Error, + { + let hash = cid::multihash::Multihash::from_bytes(v).map_err(|e| E::custom(e))?; + Ok(T::from(IpldCid::new_v1(ATP_CID_CODEC, hash))) + } + } + + deserializer.deserialize_any(StringOrBytes(PhantomData)) + } +} + +impl From> for String { + fn from(value: Cid) -> Self { + let cow_str = match value { + Cid::Ipld { cid: _, s } => s, + Cid::Str(cow_str) => cow_str, + }; + cow_str.to_string() + } +} + +impl<'d> From> for CowStr<'d> { + fn from(value: Cid<'d>) -> Self { + match value { + Cid::Ipld { cid: _, s } => s, + Cid::Str(cow_str) => cow_str, + } + } +} + +impl From for Cid<'_> { + fn from(value: String) -> Self { + Cid::Str(CowStr::Owned(value.to_compact_string())) + } +} + +impl<'d> From> for Cid<'d> { + fn from(value: CowStr<'d>) -> Self { + Cid::Str(value) + } +} + +impl From for Cid<'_> { + fn from(value: IpldCid) -> Self { + Cid::ipld(value) + } +} + +impl AsRef for Cid<'_> { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for Cid<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } } diff --git a/crates/jacquard-common/src/cowstr.rs b/crates/jacquard-common/src/cowstr.rs --- a/crates/jacquard-common/src/cowstr.rs +++ b/crates/jacquard-common/src/cowstr.rs @@ -1,11 +1,12 @@ +use compact_str::CompactString; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; use std::{ borrow::Cow, fmt, hash::{Hash, Hasher}, ops::Deref, + str::FromStr, }; - -use compact_str::CompactString; use crate::IntoStatic; @@ -208,7 +209,55 @@ } } -use serde::{Deserialize, Serialize}; +/// Common trait implementations for Lexicon string formats that are newtype wrappers +/// around `String`. +macro_rules! string_newtype { + ($name:ident) => { + impl FromStr for $name<'_> { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + Self::new(s) + } + } + + impl<'de> Deserialize<'de> for $name<'de> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Deserialize::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } + } + + impl From<$name<'_>> for String { + fn from(value: $name) -> Self { + value.0.to_string() + } + } + + impl From<$name> for CowStr<'s> { + fn from(value: $name) -> Self { + value.0 + } + } + + impl AsRef for $name<'_> { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl Deref for $name<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } + } + }; +} impl Serialize for CowStr<'_> { #[inline] diff --git a/crates/jacquard-common/src/did.rs b/crates/jacquard-common/src/did.rs --- a/crates/jacquard-common/src/did.rs +++ b/crates/jacquard-common/src/did.rs @@ -1,3 +1,143 @@ -use crate::CowStr; +use std::fmt; +use std::sync::LazyLock; +use std::{ops::Deref, str::FromStr}; +use compact_str::ToCompactString; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; + +use crate::{CowStr, IntoStatic}; +use regex::Regex; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Hash)] +#[serde(transparent)] pub struct Did<'d>(CowStr<'d>); + +pub static DID_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$").unwrap()); + +impl<'d> Did<'d> { + /// Fallible constructor, validates, borrows from input + pub fn new(did: &'d str) -> Result { + if did.len() > 2048 { + Err("DID too long") + } else if !DID_REGEX.is_match(did) { + Err("Invalid DID") + } else { + Ok(Self(CowStr::Borrowed(did))) + } + } + + /// Fallible constructor from an existing CowStr, clones and takes + pub fn from_cowstr(did: CowStr<'d>) -> Result, &'static str> { + if did.len() > 2048 { + Err("DID too long") + } else if !DID_REGEX.is_match(&did) { + Err("Invalid DID") + } else { + Ok(Self(did.into_static())) + } + } + + /// Infallible constructor for when you *know* the string is a valid DID. + /// Will panic on invalid DIDs. If you're manually decoding atproto records + /// or API values you know are valid (rather than using serde), this is the one to use. + /// The From and From impls use the same logic. + pub fn raw(did: &'d str) -> Self { + if did.len() > 2048 { + panic!("DID too long") + } else if !DID_REGEX.is_match(did) { + panic!("Invalid DID") + } else { + Self(CowStr::Borrowed(did)) + } + } + + /// Infallible constructor for when you *know* the string is a valid DID. + /// Marked unsafe because responsibility for upholding the invariant is on the developer. + pub unsafe fn unchecked(did: &'d str) -> Self { + Self(CowStr::Borrowed(did)) + } + + pub fn as_str(&self) -> &str { + { + let this = &self.0; + this + } + } +} + +impl FromStr for Did<'_> { + type Err = &'static str; + + /// Has to take ownership due to the lifetime constraints of the FromStr trait. + /// Prefer `Did::new()` or `Did::raw` if you want to borrow. + fn from_str(s: &str) -> Result { + Self::from_cowstr(CowStr::Owned(s.to_compact_string())) + } +} + +impl<'de> Deserialize<'de> for Did<'de> { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Deserialize::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + +impl fmt::Display for Did<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'d> From> for String { + fn from(value: Did<'d>) -> Self { + value.0.to_string() + } +} + +impl<'d> From> for CowStr<'d> { + fn from(value: Did<'d>) -> Self { + value.0 + } +} + +impl From for Did<'static> { + fn from(value: String) -> Self { + if value.len() > 2048 { + panic!("DID too long") + } else if !DID_REGEX.is_match(&value) { + panic!("Invalid DID") + } else { + Self(CowStr::Owned(value.to_compact_string())) + } + } +} + +impl<'d> From> for Did<'d> { + fn from(value: CowStr<'d>) -> Self { + if value.len() > 2048 { + panic!("DID too long") + } else if !DID_REGEX.is_match(&value) { + panic!("Invalid DID") + } else { + Self(value) + } + } +} + +impl AsRef for Did<'_> { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for Did<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -1,7 +1,13 @@ pub mod aturi; +#[macro_use] pub mod cowstr; +#[macro_use] +pub mod blob; +pub mod cid; + pub mod did; pub mod handle; +#[macro_use] pub mod into_static; pub mod link; pub mod nsid; -- tangled.sh