From c7cda00e41c0bdf5d7a5dc63052d0c800009d3a3 Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 12 Aug 2026 19:55:20 -0400 Subject: [PATCH] AtUri now also includes space uris. There is still a specific AtSpaceUri for when that is required (vs. optional) --- .../src/com_atproto/repo/strong_ref.rs | 39 +- crates/jacquard-common/src/types/aturi.rs | 846 ++++++++++++++++-- crates/jacquard-common/src/types/cid.rs | 92 +- 3 files changed, 897 insertions(+), 80 deletions(-) diff --git a/crates/jacquard-api/src/com_atproto/repo/strong_ref.rs b/crates/jacquard-api/src/com_atproto/repo/strong_ref.rs index 27d033403..af4668948 100644 --- a/crates/jacquard-api/src/com_atproto/repo/strong_ref.rs +++ b/crates/jacquard-api/src/com_atproto/repo/strong_ref.rs @@ -23,9 +23,9 @@ use jacquard_lexicon::schema::LexiconSchema; #[allow(unused_imports)] use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, IntoStatic)] #[serde( rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr") @@ -33,10 +33,39 @@ use serde::{Deserialize, Serialize}; pub struct StrongRef { pub cid: Cid, pub uri: AtUri, - #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + #[serde(flatten, skip_serializing_if = "Option::is_none")] pub extra_data: Option>>, } +#[derive(Deserialize)] +#[serde( + rename_all = "camelCase", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +struct StrongRefWire { + cid: Cid, + uri: AtUri, + #[serde(flatten, default)] + extra_data: BTreeMap>, +} + +impl<'de, S> Deserialize<'de> for StrongRef +where + S: BosStr + Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = StrongRefWire::::deserialize(deserializer)?; + Ok(Self { + cid: wire.cid, + uri: wire.uri, + extra_data: (!wire.extra_data.is_empty()).then_some(wire.extra_data), + }) + } +} + impl LexiconSchema for StrongRef { fn nsid() -> &'static str { "com.atproto.repo.strongRef" @@ -56,7 +85,7 @@ pub mod strong_ref_state { pub use crate::builder_types::{IsSet, IsUnset, Set, Unset}; #[allow(unused)] - use ::core::marker::PhantomData; + use core::marker::PhantomData; mod sealed { pub trait Sealed {} } @@ -204,7 +233,7 @@ where fn lexicon_doc_com_atproto_repo_strongRef() -> LexiconDoc<'static> { use alloc::collections::BTreeMap; #[allow(unused_imports)] - use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_common::{deps::smol_str::SmolStr, types::blob::MimeType, CowStr}; use jacquard_lexicon::lexicon::*; LexiconDoc { lexicon: Lexicon::Lexicon1, diff --git a/crates/jacquard-common/src/types/aturi.rs b/crates/jacquard-common/src/types/aturi.rs index 449c6b0cf..d31eea2f0 100644 --- a/crates/jacquard-common/src/types/aturi.rs +++ b/crates/jacquard-common/src/types/aturi.rs @@ -1,4 +1,5 @@ use crate::bos::{BorrowOrShare, Bos}; +use crate::types::did::Did; use crate::types::ident::AtIdentifier; use crate::types::nsid::Nsid; use crate::types::recordkey::{RecordKey, Rkey}; @@ -19,12 +20,12 @@ use regex_automata::meta::Regex; #[cfg(target_arch = "wasm32")] use regex_lite::Regex; use serde::Serializer; -use serde::{Deserialize, Deserializer, Serialize, de::Error}; +use serde::{de::Error, Deserialize, Deserializer, Serialize}; use smol_str::SmolStr; use super::Lazy; -/// Byte indices of delimiter positions within an AT URI string. +/// Byte indices of delimiter positions within a standard AT URI string. /// /// Each index points at the delimiter character itself (`/` or `#`). /// Uses `NonZeroU16` for niche optimisation — `Option` is 2 bytes. @@ -62,26 +63,25 @@ impl AtUriIndices { } } -/// AT Protocol URI (`at://`) for referencing records in repositories. +/// The parsed shape of an [`AtUri`]. /// -/// AT URIs provide a way to reference records using either a DID or handle as the authority. -/// They're not content-addressed, so the record's contents can change over time. -/// -/// Format: `at://AUTHORITY[/COLLECTION[/RKEY]][#FRAGMENT]` -/// - Authority: DID or handle identifying the repository (required) -/// - Collection: NSID of the record type (optional) -/// - Record key (rkey): specific record identifier (optional) -/// - Fragment: sub-resource identifier (optional, limited support) -/// -/// Examples: -/// - `at://alice.bsky.social` -/// - `at://did:plc:abc123/app.bsky.feed.post/3jk5` +/// Keeping the shape as an enum makes it impossible for accessors to observe a +/// partially parsed space URI or to accidentally use the standard path offsets. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum UriShape { + Standard { indices: AtUriIndices }, + Space { indices: AtSpaceUriIndices }, +} + +/// AT Protocol URI (`at://`) for referencing records in repositories or +/// permissioned spaces. /// -/// See: +/// Public record form: `at://AUTHORITY/COLLECTION/RKEY`. +/// Permissioned record form: `at://SPACE_DID/space/SPACE_TYPE/SKEY/AUTHOR/COLLECTION/RKEY`. #[derive(Clone, Debug)] pub struct AtUri + AsRef = DefaultStr> { uri: S, - indices: AtUriIndices, + shape: UriShape, } impl + AsRef> PartialEq for AtUri { @@ -269,43 +269,49 @@ fn extract_indices(uri: &str) -> AtUriIndices { // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- -// Generic unchecked construction +// Generic construction // --------------------------------------------------------------------------- +fn validate_uri_shape(uri: &str) -> Result { + // The space marker is positional: only the first path segment after the + // authority may carry it. A standard URI with a collection or rkey that + // happens to be "space" must parse as standard. + let after_scheme = uri + .strip_prefix("at://") + .unwrap_or(uri); + let first_path_segment = after_scheme.split('/').nth(1); + if first_path_segment == Some("space") { + validate_space_and_index(uri).map(|indices| UriShape::Space { indices }) + } else { + validate_and_index(uri).map(|indices| UriShape::Standard { indices }) + } +} + impl + AsRef> AtUri { /// Unchecked constructor from a pre-validated URI string. /// - /// Extracts indices but does not validate components. Use when the URI - /// has already been validated externally (e.g., by `AtprotoStr::new()`). - /// /// # Safety - /// - /// Callers must ensure the URI is a valid AT URI. Accessor methods will - /// produce typed wrappers via `unchecked` constructors. + /// The caller must ensure the URI is a valid standard or permissioned-space URI. pub unsafe fn unchecked(uri: S) -> Self { - let indices = extract_indices(uri.as_ref()); - AtUri { uri, indices } + let shape = validate_uri_shape(uri.as_ref()).expect("valid AT URI"); + AtUri { uri, shape } } - /// Construct from a pre-validated URI string and pre-computed indices. + /// Construct a standard URI from pre-validated storage and indices. /// /// # Safety - /// - /// Callers must ensure the URI is valid and the indices are correct. + /// The caller must ensure the URI and indices describe a valid standard URI. pub(crate) unsafe fn from_parts(uri: S, indices: AtUriIndices) -> Self { - AtUri { uri, indices } + AtUri { + uri, + shape: UriShape::Standard { indices }, + } } -} -// --------------------------------------------------------------------------- -// Generic construction -// --------------------------------------------------------------------------- - -impl + AsRef> AtUri { - /// Fallible constructor, validates, wraps the input directly. + /// Fallible constructor, validates and wraps the input directly. pub fn new(uri: S) -> Result { - let indices = validate_and_index(uri.as_ref())?; - Ok(AtUri { uri, indices }) + let shape = validate_uri_shape(uri.as_ref())?; + Ok(AtUri { uri, shape }) } /// Infallible constructor. Panics on invalid URIs. @@ -322,7 +328,7 @@ impl + AsRef + FromStr> AtUri { /// Fallible owned constructor. pub fn new_owned(uri: impl AsRef) -> Result { let uri_str = uri.as_ref(); - let indices = validate_and_index(uri_str)?; + let shape = validate_uri_shape(uri_str)?; let s = S::from_str(uri_str).map_err(|_| { AtStrError::new( "at-uri-scheme", @@ -330,10 +336,20 @@ impl + AsRef + FromStr> AtUri { StrParseKind::Conversion, ) })?; - Ok(AtUri { uri: s, indices }) + Ok(AtUri { uri: s, shape }) + } + + /// Construct an `AtUri` from a validated permissioned-space URI. + pub fn from_space_uri(uri: impl AsRef) -> Result { + let uri = uri.as_ref(); + let shape = validate_space_and_index(uri).map(|indices| UriShape::Space { indices })?; + let value = S::from_str(uri).map_err(|_| { + AtStrError::new("at-uri-scheme", uri.to_string(), StrParseKind::Conversion) + })?; + Ok(Self { uri: value, shape }) } - /// Fallible constructor from typical parts. + /// Fallible constructor from typical public-repository parts. pub fn from_parts_owned( authority: impl AsRef, collection: impl AsRef, @@ -361,11 +377,11 @@ impl + AsRef + FromStr> AtUri { /// Fallible constructor for static strings. pub fn new_static(uri: &'static str) -> Result { - let indices = validate_and_index(uri)?; + let shape = validate_uri_shape(uri)?; let s = S::from_str(uri).map_err(|_| { AtStrError::new("at-uri-scheme", uri.to_string(), StrParseKind::Conversion) })?; - Ok(AtUri { uri: s, indices }) + Ok(AtUri { uri: s, shape }) } } @@ -379,54 +395,155 @@ impl + AsRef> AtUri { self.uri.as_ref() } - /// Get the authority component (DID or handle). - /// - /// Uses `BorrowOrShare` split lifetimes: when `S = &'d str`, the returned - /// `AtIdentifier<&'d str>` can outlive the borrow of `self`. + /// Return whether this URI addresses a permissioned space. + pub fn is_space(&self) -> bool { + matches!(self.shape, UriShape::Space { .. }) + } + + /// Get the authority component (the space DID for space URIs). pub fn authority<'i, 'o>(&'i self) -> AtIdentifier<&'o str> where S: BorrowOrShare<'i, 'o, str>, { let s: &'o str = self.uri.borrow_or_share(); - let end = self.indices.authority_end(s.len()); - // Safety: constructor validated the authority. `unchecked` classifies DID vs handle - // but won't reject valid input. + let end = match self.shape { + UriShape::Standard { indices } => indices.authority_end(s.len()), + UriShape::Space { indices } => indices.first_slash.get() as usize, + }; unsafe { AtIdentifier::unchecked(&s[5..end]) } } - /// Get the collection NSID from the path, if present. + /// Get the space authority DID, if this is a space URI. + pub fn space_did<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let UriShape::Space { indices } = self.shape else { + return None; + }; + let s: &'o str = self.uri.borrow_or_share(); + Some(unsafe { Did::unchecked(&s[5..indices.first_slash.get() as usize]) }) + } + + /// Get the space type NSID, if this is a space URI. + pub fn space_type<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let UriShape::Space { indices } = self.shape else { + return None; + }; + let s: &'o str = self.uri.borrow_or_share(); + Some(unsafe { + Nsid::unchecked( + &s[indices.second_slash.get() as usize + 1..indices.third_slash.get() as usize], + ) + }) + } + + /// Get the space record key, if this is a space URI. + pub fn skey<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let UriShape::Space { indices } = self.shape else { + return None; + }; + let s: &'o str = self.uri.borrow_or_share(); + Some(unsafe { + Rkey::unchecked(&s[indices.third_slash.get() as usize + 1..indices.skey_end(s.len())]) + }) + } + + /// Get the record author. For public URIs this is the authority; for + /// space URIs it is the member DID after the space identity. + pub fn author<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let s: &'o str = self.uri.borrow_or_share(); + match self.shape { + UriShape::Standard { .. } => Some(self.authority()), + UriShape::Space { indices } => { + let slash = indices.fourth_slash?; + Some(unsafe { + AtIdentifier::unchecked( + &s[slash.get() as usize + 1..indices.author_end(s.len())], + ) + }) + } + } + } + + /// Get the collection NSID from either URI shape, if present. pub fn collection<'i, 'o>(&'i self) -> Option> where S: BorrowOrShare<'i, 'o, str>, { - let idx = self.indices.first_slash?.get() as usize; let s: &'o str = self.uri.borrow_or_share(); - let end = self.indices.collection_end(s.len()); - Some(unsafe { Nsid::unchecked(&s[idx + 1..end]) }) + match self.shape { + UriShape::Standard { indices } => { + let idx = indices.first_slash?.get() as usize; + let end = indices.collection_end(s.len()); + Some(unsafe { Nsid::unchecked(&s[idx + 1..end]) }) + } + UriShape::Space { indices } => { + let slash = indices.fifth_slash?; + let end = indices + .sixth_slash + .map(|n| n.get() as usize) + .unwrap_or(s.len()); + Some(unsafe { Nsid::unchecked(&s[slash.get() as usize + 1..end]) }) + } + } } - /// Get the record key from the path, if present. + /// Get the record key from either URI shape, if present. pub fn rkey<'i, 'o>(&'i self) -> Option> where S: BorrowOrShare<'i, 'o, str>, { - let idx = self.indices.second_slash?.get() as usize; let s: &'o str = self.uri.borrow_or_share(); - let end = self.indices.rkey_end(s.len()); - Some(unsafe { Rkey::unchecked(&s[idx + 1..end]) }) + match self.shape { + UriShape::Standard { indices } => { + let idx = indices.second_slash?.get() as usize; + let end = indices.rkey_end(s.len()); + Some(unsafe { Rkey::unchecked(&s[idx + 1..end]) }) + } + UriShape::Space { indices } => { + let slash = indices.sixth_slash?; + Some(unsafe { Rkey::unchecked(&s[slash.get() as usize + 1..]) }) + } + } + } + + /// Return the space identity represented by this URI, if any. + pub fn space_ref<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + Some(SpaceRef { + space_did: self.space_did()?, + space_type: self.space_type()?, + skey: self.skey()?, + }) } - /// Get the path component (collection and optional rkey). + /// Get the public-repository path. Space records expose their record + /// collection and key through `collection()` and `rkey()` instead. pub fn path<'i, 'o>(&'i self) -> Option> where S: BorrowOrShare<'i, 'o, str>, { - let slash = self.indices.first_slash?.get() as usize; + let UriShape::Standard { indices } = self.shape else { + return None; + }; + let slash = indices.first_slash?.get() as usize; let s: &'o str = self.uri.borrow_or_share(); - let col_end = self.indices.collection_end(s.len()); + let col_end = indices.collection_end(s.len()); let collection = unsafe { Nsid::unchecked(&s[slash + 1..col_end]) }; - let rkey = self.indices.second_slash.map(|idx| { - let rkey_end = self.indices.rkey_end(s.len()); + let rkey = indices.second_slash.map(|idx| { + let rkey_end = indices.rkey_end(s.len()); RecordKey(unsafe { Rkey::unchecked(&s[idx.get() as usize + 1..rkey_end]) }) }); Some(RepoPath { collection, rkey }) @@ -437,9 +554,12 @@ impl + AsRef> AtUri { where S: BorrowOrShare<'i, 'o, str>, { - let idx = self.indices.hash?.get() as usize; + let idx = match self.shape { + UriShape::Standard { indices } => indices.hash?, + UriShape::Space { .. } => return None, + }; let s: &'o str = self.uri.borrow_or_share(); - Some(&s[idx + 1..]) + Some(&s[idx.get() as usize + 1..]) } } @@ -456,7 +576,7 @@ where fn into_static(self) -> AtUri { AtUri { uri: self.uri.into_static(), - indices: self.indices, + shape: self.shape, } } } @@ -466,7 +586,7 @@ impl + AsRef> AtUri { pub fn convert + AsRef + From>(self) -> AtUri { AtUri { uri: B::from(self.uri), - indices: self.indices, + shape: self.shape, } } } @@ -484,8 +604,8 @@ where D: Deserializer<'de>, { let s = S::deserialize(deserializer)?; - let indices = validate_and_index(s.as_ref()).map_err(D::Error::custom)?; - Ok(AtUri { uri: s, indices }) + let shape = validate_uri_shape(s.as_ref()).map_err(D::Error::custom)?; + Ok(AtUri { uri: s, shape }) } } @@ -556,6 +676,516 @@ impl + AsRef> Deref for AtUri { } } +// --------------------------------------------------------------------------- +// Permissioned space URIs +// --------------------------------------------------------------------------- + +/// Byte indices of the slash delimiters in a permissioned space URI. +/// +/// Each index points at the delimiter itself. `NonZeroU16` preserves the same +/// niche optimization used by [`AtUri`]: an absent index costs no additional +/// space and a valid delimiter can never occur at zero. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct AtSpaceUriIndices { + first_slash: NonZeroU16, + second_slash: NonZeroU16, + third_slash: NonZeroU16, + fourth_slash: Option, + fifth_slash: Option, + sixth_slash: Option, +} + +impl AtSpaceUriIndices { + fn skey_end(&self, len: usize) -> usize { + self.fourth_slash.map(|n| n.get() as usize).unwrap_or(len) + } + + fn author_end(&self, len: usize) -> usize { + self.fifth_slash.map(|n| n.get() as usize).unwrap_or(len) + } +} + +/// The `/space/{spaceType}/{skey}` portion of a permissioned space URI. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct SpacePath + AsRef = DefaultStr> { + /// The NSID identifying the space type declaration. + pub space_type: Nsid, + /// The record-key-shaped identifier selecting a space of that type. + pub skey: RecordKey>, +} + +impl + AsRef> fmt::Display for SpacePath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "/space/{}/{}", self.space_type, self.skey.as_ref()) + } +} + +impl + AsRef + IntoStatic> IntoStatic for SpacePath +where + S::Output: Bos + AsRef, +{ + type Output = SpacePath; + + fn into_static(self) -> Self::Output { + SpacePath { + space_type: self.space_type.into_static(), + skey: RecordKey(self.skey.0.into_static()), + } + } +} + +/// A reference to a permissioned space, without a record path. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct SpaceRef + AsRef = DefaultStr> { + /// DID of the repository hosting the permissioned space. + pub space_did: Did, + /// NSID identifying the space type. + pub space_type: Nsid, + /// Record-key-shaped identifier selecting the space. + pub skey: Rkey, +} + +impl + AsRef> fmt::Display for SpaceRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "at://{}/space/{}/{}", + self.space_did, self.space_type, self.skey + ) + } +} + +impl + AsRef + IntoStatic> IntoStatic for SpaceRef +where + S::Output: Bos + AsRef, +{ + type Output = SpaceRef; + + fn into_static(self) -> Self::Output { + SpaceRef { + space_did: self.space_did.into_static(), + space_type: self.space_type.into_static(), + skey: self.skey.into_static(), + } + } +} + +/// Permissioned space URI. +/// +/// The accepted forms are: +/// +/// ```text +/// at://AUTHORITY/space/SPACE_TYPE/SKEY +/// at://AUTHORITY/space/SPACE_TYPE/SKEY/AUTHOR/COLLECTION/RKEY +/// ``` +/// +/// This is intentionally a separate type from [`AtUri`]. It follows AtUri's +/// generic backing storage, byte-index accessors, serde, `IntoStatic`, and +/// niche optimization without broadening ordinary AT URI validation. +#[derive(Clone, Debug)] +pub struct AtSpaceUri + AsRef = DefaultStr> { + uri: S, + indices: AtSpaceUriIndices, +} + +impl + AsRef> PartialEq for AtSpaceUri { + fn eq(&self, other: &Self) -> bool { + self.uri.as_ref() == other.uri.as_ref() + } +} + +impl + AsRef> Eq for AtSpaceUri {} + +impl + AsRef> Hash for AtSpaceUri { + fn hash(&self, state: &mut H) { + self.uri.as_ref().hash(state); + } +} + +/// Regex for the two canonical permissioned space URI forms. +pub static ATSPACEURI_REGEX: Lazy = Lazy::new(|| { + Regex::new( + r"^at://(?[a-zA-Z0-9._:%-]+)/space/(?[a-zA-Z0-9.-]+)/(?[a-zA-Z0-9._~:@!$&%')(*+,;=-]+)(/(?[a-zA-Z0-9._:%-]+)/(?[a-zA-Z0-9.-]+)/(?[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?$", + ) + .unwrap() +}); + +fn validate_space_and_index(uri: &str) -> Result { + let Some(parts) = ATSPACEURI_REGEX.captures(uri) else { + return Err(AtStrError::regex( + "at-space-uri-scheme", + uri, + SmolStr::new_static("doesn't match schema"), + )); + }; + + let authority = parts + .name("authority") + .ok_or_else(|| AtStrError::missing("at-space-uri-scheme", uri, "authority"))?; + Did::new(authority.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + + let space_type = parts + .name("space_type") + .ok_or_else(|| AtStrError::missing("at-space-uri-scheme", uri, "space type"))?; + Nsid::new(space_type.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + + let skey = parts + .name("skey") + .ok_or_else(|| AtStrError::missing("at-space-uri-scheme", uri, "skey"))?; + Rkey::new(skey.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + + if let Some(author) = parts.name("author") { + Did::new(author.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + let collection = parts + .name("collection") + .ok_or_else(|| AtStrError::missing("at-space-uri-scheme", uri, "record collection"))?; + Nsid::new(collection.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + let rkey = parts + .name("rkey") + .ok_or_else(|| AtStrError::missing("at-space-uri-scheme", uri, "record key"))?; + Rkey::new(rkey.as_str()) + .map_err(|e| AtStrError::wrap("at-space-uri-scheme", uri.to_string(), e))?; + } + + Ok(extract_space_indices(uri)) +} + +fn extract_space_indices(uri: &str) -> AtSpaceUriIndices { + let bytes = uri.as_bytes(); + let mut slashes = [None; 6]; + let mut found = 0; + for (i, byte) in bytes.iter().enumerate().skip(5) { + if *byte == b'/' && found < slashes.len() { + slashes[found] = NonZeroU16::new(i as u16); + found += 1; + } + } + AtSpaceUriIndices { + first_slash: slashes[0].expect("validated space URI has authority slash"), + second_slash: slashes[1].expect("validated space URI has space slash"), + third_slash: slashes[2].expect("validated space URI has skey slash"), + fourth_slash: slashes[3], + fifth_slash: slashes[4], + sixth_slash: slashes[5], + } +} + +impl + AsRef> AtSpaceUri { + /// Construct from a pre-validated URI and compute component indices. + /// + /// # Safety + /// The caller must ensure `uri` is a valid canonical permissioned URI. + pub unsafe fn unchecked(uri: S) -> Self { + let indices = extract_space_indices(uri.as_ref()); + Self { uri, indices } + } + + /// Parse a canonical permissioned space URI from its backing string. + /// + /// Both the space authority and an optional record author must be DIDs; + /// handles are intentionally rejected for proposal-0016 addressing. + pub fn new(uri: S) -> Result { + let indices = validate_space_and_index(uri.as_ref())?; + Ok(Self { uri, indices }) + } + + /// Construct a permissioned space URI, panicking when the input is invalid. + /// + /// Prefer [`Self::new`] when the value is not a compile-time constant. + #[track_caller] + pub fn raw(uri: S) -> Self { + Self::new(uri).expect("valid permissioned space URI") + } + + /// Return the complete canonical URI string. + pub fn as_str(&self) -> &str { + self.uri.as_ref() + } + + /// Return the authority as an AT identifier. + /// + /// Permissioned URI validation guarantees this identifier is always a DID. + pub fn authority<'i, 'o>(&'i self) -> AtIdentifier<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + let value: &'o str = self.uri.borrow_or_share(); + unsafe { AtIdentifier::unchecked(&value[5..self.indices.first_slash.get() as usize]) } + } + + /// Return the space authority as a validated DID. + pub fn did_authority<'i, 'o>(&'i self) -> Did<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + let value: &'o str = self.uri.borrow_or_share(); + unsafe { Did::unchecked(&value[5..self.indices.first_slash.get() as usize]) } + } + + /// Return the NSID identifying this URI's space type. + pub fn space_type<'i, 'o>(&'i self) -> Nsid<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + let value: &'o str = self.uri.borrow_or_share(); + unsafe { + Nsid::unchecked( + &value[self.indices.second_slash.get() as usize + 1 + ..self.indices.third_slash.get() as usize], + ) + } + } + + /// Return the record-key-shaped identifier of this space. + pub fn skey<'i, 'o>(&'i self) -> Rkey<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + let value: &'o str = self.uri.borrow_or_share(); + unsafe { + Rkey::unchecked( + &value[self.indices.third_slash.get() as usize + 1 + ..self.indices.skey_end(value.len())], + ) + } + } + + /// Return the optional record author as an AT identifier. + /// + /// Permissioned URI validation guarantees a present author is always a DID. + pub fn author<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let slash = self.indices.fourth_slash?; + let value: &'o str = self.uri.borrow_or_share(); + unsafe { + Some(AtIdentifier::unchecked( + &value[slash.get() as usize + 1..self.indices.author_end(value.len())], + )) + } + } + + /// Return the optional record author as a validated DID. + pub fn did_author<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let slash = self.indices.fourth_slash?; + let value: &'o str = self.uri.borrow_or_share(); + unsafe { + Some(Did::unchecked( + &value[slash.get() as usize + 1..self.indices.author_end(value.len())], + )) + } + } + + /// Return the optional record collection NSID. + pub fn collection<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let slash = self.indices.fifth_slash?; + let value: &'o str = self.uri.borrow_or_share(); + let end = self + .indices + .sixth_slash + .map(|n| n.get() as usize) + .unwrap_or(value.len()); + unsafe { Some(Nsid::unchecked(&value[slash.get() as usize + 1..end])) } + } + + /// Return the optional record key. + pub fn rkey<'i, 'o>(&'i self) -> Option> + where + S: BorrowOrShare<'i, 'o, str>, + { + let slash = self.indices.sixth_slash?; + let value: &'o str = self.uri.borrow_or_share(); + unsafe { Some(Rkey::unchecked(&value[slash.get() as usize + 1..])) } + } + + /// Return the reusable `/space/{spaceType}/{skey}` path components. + pub fn path<'i, 'o>(&'i self) -> SpacePath<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + SpacePath { + space_type: self.space_type(), + skey: RecordKey(self.skey()), + } + } + + /// Return whether this URI includes author, collection, and record-key components. + pub fn is_record(&self) -> bool { + self.indices.fourth_slash.is_some() + } +} + +impl + AsRef + FromStr> AtSpaceUri { + /// Parse a canonical permissioned URI into owned backing storage. + pub fn new_owned(uri: impl AsRef) -> Result { + let uri = uri.as_ref(); + let indices = validate_space_and_index(uri)?; + let value = S::from_str(uri).map_err(|_| { + AtStrError::new( + "at-space-uri-scheme", + uri.to_string(), + StrParseKind::Conversion, + ) + })?; + Ok(Self { + uri: value, + indices, + }) + } + + /// Parse a canonical permissioned URI from a static string. + pub fn new_static(uri: &'static str) -> Result { + Self::new_owned(uri) + } +} + +impl + AsRef + IntoStatic> IntoStatic for AtSpaceUri +where + S::Output: Bos + AsRef, +{ + type Output = AtSpaceUri; + + fn into_static(self) -> Self::Output { + AtSpaceUri { + uri: self.uri.into_static(), + indices: self.indices, + } + } +} + +impl + AsRef> AtSpaceUri { + /// Convert the URI to another compatible backing-storage type without reparsing. + pub fn convert + AsRef + From>(self) -> AtSpaceUri { + AtSpaceUri { + uri: B::from(self.uri), + indices: self.indices, + } + } + + /// Return the identity of the space addressed by this URI. + pub fn space_ref<'i, 'o>(&'i self) -> SpaceRef<&'o str> + where + S: BorrowOrShare<'i, 'o, str>, + { + SpaceRef { + space_did: self.did_authority(), + space_type: self.space_type(), + skey: self.skey(), + } + } +} + +impl + AsRef> From> for AtUri +where + S: FromStr, +{ + fn from(value: AtSpaceUri) -> Self { + let AtSpaceUri { uri, indices } = value; + AtUri { + uri, + shape: UriShape::Space { indices }, + } + } +} + +impl + AsRef> From> for Option> { + fn from(value: AtUri) -> Self { + let AtUri { uri, shape } = value; + match shape { + UriShape::Space { indices } => Some(AtSpaceUri { uri, indices }), + UriShape::Standard { .. } => None, + } + } +} + +impl<'de, S> Deserialize<'de> for AtSpaceUri +where + S: Bos + AsRef + Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = S::deserialize(deserializer)?; + let indices = validate_space_and_index(value.as_ref()).map_err(D::Error::custom)?; + Ok(Self { + uri: value, + indices, + }) + } +} + +impl + AsRef> Serialize for AtSpaceUri { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: Serializer, + { + serializer.serialize_str(self.uri.as_ref()) + } +} + +impl FromStr for AtSpaceUri { + type Err = AtStrError; + + fn from_str(uri: &str) -> Result { + Self::new_owned(uri) + } +} + +impl + AsRef> fmt::Display for AtSpaceUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.uri.as_ref()) + } +} + +impl + AsRef> From> for String { + fn from(value: AtSpaceUri) -> Self { + value.uri.as_ref().to_string() + } +} + +impl TryFrom for AtSpaceUri { + type Error = AtStrError; + + fn try_from(value: String) -> Result { + Self::new_owned(value) + } +} + +impl<'d> TryFrom> for AtSpaceUri> { + type Error = AtStrError; + + fn try_from(uri: CowStr<'d>) -> Result { + Self::new(uri) + } +} + +impl + AsRef> AsRef for AtSpaceUri { + fn as_ref(&self) -> &str { + self.uri.as_ref() + } +} + +impl + AsRef> Deref for AtSpaceUri { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.uri.as_ref() + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -703,4 +1333,80 @@ mod tests { // authority borrows from s, not from uri — this is the BOS magic. assert_eq!(authority.as_str(), "alice.test"); } + + #[test] + fn at_uri_accepts_public_and_space_record_forms() { + let public = AtUri::new("at://did:plc:author/com.example.record/r").unwrap(); + assert!(!public.is_space()); + assert_eq!(public.author().unwrap().as_str(), "did:plc:author"); + assert_eq!(public.collection().unwrap().as_str(), "com.example.record"); + assert_eq!(public.rkey().unwrap().as_ref(), "r"); + assert!(public.space_ref().is_none()); + // "space" as a rkey or later segment is standard, not a space marker: + // the marker is positional (first path segment after the authority). + let rkey_space = AtUri::new("at://did:plc:author/com.example.record/space").unwrap(); + assert!(!rkey_space.is_space()); + assert_eq!(rkey_space.rkey().unwrap().as_ref(), "space"); + + let space = AtUri::new_owned( + "at://did:plc:space/space/com.example.type/demo/did:plc:author/com.example.record/r", + ) + .unwrap(); + assert!(space.is_space()); + assert_eq!(space.space_did().unwrap().as_str(), "did:plc:space"); + assert_eq!(space.space_type().unwrap().as_str(), "com.example.type"); + assert_eq!(space.skey().unwrap().as_ref(), "demo"); + assert_eq!(space.author().unwrap().as_str(), "did:plc:author"); + assert_eq!(space.collection().unwrap().as_str(), "com.example.record"); + assert_eq!(space.rkey().unwrap().as_ref(), "r"); + assert_eq!( + space.space_ref().unwrap().to_string(), + "at://did:plc:space/space/com.example.type/demo" + ); + + let json = serde_json::to_string(&space).unwrap(); + let decoded: AtUri = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, space); + let space_uri: AtSpaceUri = AtSpaceUri::new_owned(space.as_str()).unwrap(); + assert_eq!(AtUri::from(space_uri).as_str(), space.as_str()); + } + + #[test] + fn permissioned_space_and_record_forms() { + let space = AtSpaceUri::new("at://did:plc:space/space/com.example.type/demo").unwrap(); + assert_eq!(space.authority().as_str(), "did:plc:space"); + assert_eq!(space.space_type().as_str(), "com.example.type"); + assert_eq!(space.skey().as_str(), "demo"); + assert!(!space.is_record()); + + let record = AtSpaceUri::new( + "at://did:plc:space/space/com.example.type/demo/did:plc:author/com.example.record/r", + ) + .unwrap(); + assert_eq!(record.author().unwrap().as_str(), "did:plc:author"); + assert_eq!(record.collection().unwrap().as_str(), "com.example.record"); + assert_eq!(record.rkey().unwrap().as_str(), "r"); + assert!(record.is_record()); + } + + #[test] + fn permissioned_space_rejects_noncanonical_forms() { + assert!(AtSpaceUri::new("at://did:plc:space/com.example.type/demo").is_err()); + assert!(AtSpaceUri::new("at://did:plc:space/space/com.example.type/demo/").is_err()); + assert!(AtSpaceUri::new( + "at://did:plc:space/space/com.example.type/demo/did:plc:author/com.example.record" + ) + .is_err()); + } + + #[test] + fn permissioned_space_serde_roundtrip() { + let original = + "at://did:plc:space/space/com.example.type/demo/did:plc:author/com.example.record/r"; + let uri: AtSpaceUri = AtSpaceUri::new_owned(original).unwrap(); + let json = serde_json::to_string(&uri).unwrap(); + let decoded: AtSpaceUri = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, uri); + assert_eq!(decoded.path().space_type.as_str(), "com.example.type"); + } } diff --git a/crates/jacquard-common/src/types/cid.rs b/crates/jacquard-common/src/types/cid.rs index 50b47c80f..4e633ddc1 100644 --- a/crates/jacquard-common/src/types/cid.rs +++ b/crates/jacquard-common/src/types/cid.rs @@ -3,7 +3,7 @@ use crate::{CowStr, IntoStatic}; use alloc::string::{String, ToString}; pub use cid::Cid as IpldCid; use core::{convert::Infallible, fmt, ops::Deref, str::FromStr}; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor}; +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use smol_str::{SmolStr, ToSmolStr}; /// CID codec for AT Protocol (raw). @@ -190,12 +190,94 @@ where where D: Deserializer<'de>, { + // Use one visitor for text and binary forms. This matters when serde + // replays an internally tagged enum through its private ContentDeserializer: + // that adapter reports human-readable=true even though the original value + // came from DAG-CBOR, and represents tag-42 as a newtype around bytes. + struct CidVisitor(core::marker::PhantomData); + impl<'de, S> Visitor<'de> for CidVisitor + where + S: Bos + AsRef + Deserialize<'de>, + { + type Value = Cid; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a CID link") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + let s = S::deserialize(serde::de::value::StrDeserializer::::new(value))?; + Ok(Cid::Str(s)) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: serde::de::Error, + { + self.visit_str(value) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + let s = S::deserialize(serde::de::value::StringDeserializer::::new(value))?; + Ok(Cid::Str(s)) + } + + fn visit_bytes(self, value: &[u8]) -> Result + where + E: serde::de::Error, + { + let cid = IpldCid::try_from(value).map_err(E::custom)?; + Ok(Cid::ipld(cid)) + } + + fn visit_byte_buf(self, value: alloc::vec::Vec) -> Result + where + E: serde::de::Error, + { + self.visit_bytes(&value) + } + + fn visit_newtype_struct(self, deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct BytesVisitor; + impl<'de> Visitor<'de> for BytesVisitor { + type Value = alloc::vec::Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("CID bytes") + } + + fn visit_bytes( + self, + value: &[u8], + ) -> Result { + Ok(value.to_vec()) + } + + fn visit_byte_buf( + self, + value: alloc::vec::Vec, + ) -> Result { + Ok(value) + } + } + + let bytes = deserializer.deserialize_bytes(BytesVisitor)?; + self.visit_byte_buf(bytes) + } + } + if deserializer.is_human_readable() { - // JSON: deserialize S (string), wrap in Str variant. - let s = S::deserialize(deserializer)?; - Ok(Cid::Str(s)) + deserializer.deserialize_any(CidVisitor(core::marker::PhantomData)) } else { - // CBOR/postcard: use IpldCid's deserializer for canonical CID bytes. let cid = IpldCid::deserialize(deserializer)?; Ok(Cid::ipld(cid)) } -- 2.51.2