···11-use serde::{Deserialize, Serialize};
11+use serde::{Deserialize, Deserializer, Serialize};
22use smol_str::SmolStr;
33use std::{
44 borrow::Cow,
···283283 }
284284}
285285286286+/// Deserialization helper for things that wrap a CowStr
287287+pub struct CowStrVisitor;
288288+289289+impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
290290+ type Value = CowStr<'de>;
291291+292292+ #[inline]
293293+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
294294+ write!(formatter, "a string")
295295+ }
296296+297297+ #[inline]
298298+ fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
299299+ where
300300+ E: serde::de::Error,
301301+ {
302302+ Ok(CowStr::copy_from_str(v))
303303+ }
304304+305305+ #[inline]
306306+ fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
307307+ where
308308+ E: serde::de::Error,
309309+ {
310310+ Ok(CowStr::Borrowed(v))
311311+ }
312312+313313+ #[inline]
314314+ fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
315315+ where
316316+ E: serde::de::Error,
317317+ {
318318+ Ok(v.into())
319319+ }
320320+}
321321+286322impl<'de, 'a> Deserialize<'de> for CowStr<'a>
287323where
288324 'de: 'a,
···292328 where
293329 D: serde::Deserializer<'de>,
294330 {
295295- struct CowStrVisitor;
296296-297297- impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
298298- type Value = CowStr<'de>;
299299-300300- #[inline]
301301- fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
302302- write!(formatter, "a string")
303303- }
304304-305305- #[inline]
306306- fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
307307- where
308308- E: serde::de::Error,
309309- {
310310- Ok(CowStr::copy_from_str(v))
311311- }
312312-313313- #[inline]
314314- fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
315315- where
316316- E: serde::de::Error,
317317- {
318318- Ok(CowStr::Borrowed(v))
319319- }
320320-321321- #[inline]
322322- fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
323323- where
324324- E: serde::de::Error,
325325- {
326326- Ok(v.into())
327327- }
328328- }
329329-330331 deserializer.deserialize_str(CowStrVisitor)
331332 }
333333+}
334334+335335+/// Serde helper for deserializing stuff when you want an owned version
336336+pub fn deserialize_owned<'de, T, D>(deserializer: D) -> Result<<T as IntoStatic>::Output, D::Error>
337337+where
338338+ T: Deserialize<'de> + IntoStatic,
339339+ D: Deserializer<'de>,
340340+{
341341+ let value = T::deserialize(deserializer)?;
342342+ Ok(value.into_static())
332343}
333344334345/// Convert to a CowStr.
+3
crates/jacquard-common/src/lib.rs
···213213pub mod macros;
214214/// Generic session storage traits and utilities.
215215pub mod session;
216216+/// Service authentication JWT parsing and verification.
217217+#[cfg(feature = "service-auth")]
218218+pub mod service_auth;
216219/// Baseline fundamental AT Protocol data types.
217220pub mod types;
218221// XRPC protocol types and traits
+480
crates/jacquard-common/src/service_auth.rs
···11+//! Service authentication JWT parsing and verification for AT Protocol.
22+//!
33+//! Service auth is atproto's inter-service authentication mechanism. When a backend
44+//! service (feed generator, labeler, etc.) receives requests, the PDS signs a
55+//! short-lived JWT with the user's signing key and includes it as a Bearer token.
66+//!
77+//! # JWT Structure
88+//!
99+//! - Header: `alg` (ES256K for k256, ES256 for p256), `typ` ("JWT")
1010+//! - Payload:
1111+//! - `iss`: user's DID (issuer)
1212+//! - `aud`: target service DID (audience)
1313+//! - `exp`: expiration unix timestamp
1414+//! - `iat`: issued at unix timestamp
1515+//! - `jti`: random nonce (128-bit hex) for replay protection
1616+//! - `lxm`: lexicon method NSID (method binding)
1717+//! - Signature: signed with user's signing key from DID doc (ES256 or ES256K)
1818+1919+use crate::CowStr;
2020+use crate::IntoStatic;
2121+use crate::types::string::{Did, Nsid};
2222+use base64::Engine;
2323+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2424+use ouroboros::self_referencing;
2525+use serde::{Deserialize, Serialize};
2626+use signature::Verifier;
2727+use smol_str::SmolStr;
2828+use smol_str::format_smolstr;
2929+use thiserror::Error;
3030+3131+#[cfg(feature = "crypto-p256")]
3232+use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey};
3333+3434+#[cfg(feature = "crypto-k256")]
3535+use k256::ecdsa::{Signature as K256Signature, VerifyingKey as K256VerifyingKey};
3636+3737+/// Errors that can occur during JWT parsing and verification.
3838+#[derive(Debug, Error, miette::Diagnostic)]
3939+pub enum ServiceAuthError {
4040+ /// JWT format is invalid (not three base64-encoded parts separated by dots)
4141+ #[error("malformed JWT: {0}")]
4242+ MalformedToken(CowStr<'static>),
4343+4444+ /// Base64 decoding failed
4545+ #[error("base64 decode error: {0}")]
4646+ Base64Decode(#[from] base64::DecodeError),
4747+4848+ /// JSON parsing failed
4949+ #[error("JSON parsing error: {0}")]
5050+ JsonParse(#[from] serde_json::Error),
5151+5252+ /// Signature verification failed
5353+ #[error("invalid signature")]
5454+ InvalidSignature,
5555+5656+ /// Unsupported algorithm
5757+ #[error("unsupported algorithm: {alg}")]
5858+ UnsupportedAlgorithm {
5959+ /// Algorithm name from JWT header
6060+ alg: SmolStr,
6161+ },
6262+6363+ /// Token has expired
6464+ #[error("token expired at {exp} (current time: {now})")]
6565+ Expired {
6666+ /// Expiration timestamp from token
6767+ exp: i64,
6868+ /// Current timestamp
6969+ now: i64,
7070+ },
7171+7272+ /// Audience mismatch
7373+ #[error("audience mismatch: expected {expected}, got {actual}")]
7474+ AudienceMismatch {
7575+ /// Expected audience DID
7676+ expected: Did<'static>,
7777+ /// Actual audience DID in token
7878+ actual: Did<'static>,
7979+ },
8080+8181+ /// Method mismatch (lxm field)
8282+ #[error("method mismatch: expected {expected}, got {actual:?}")]
8383+ MethodMismatch {
8484+ /// Expected method NSID
8585+ expected: Nsid<'static>,
8686+ /// Actual method NSID in token (if any)
8787+ actual: Option<Nsid<'static>>,
8888+ },
8989+9090+ /// Missing required field
9191+ #[error("missing required field: {0}")]
9292+ MissingField(&'static str),
9393+9494+ /// Crypto error
9595+ #[error("crypto error: {0}")]
9696+ Crypto(CowStr<'static>),
9797+}
9898+9999+/// JWT header for service auth tokens.
100100+#[derive(Debug, Clone, Serialize, Deserialize)]
101101+pub struct JwtHeader<'a> {
102102+ /// Algorithm used for signing
103103+ #[serde(borrow)]
104104+ pub alg: CowStr<'a>,
105105+ /// Type (always "JWT")
106106+ #[serde(borrow)]
107107+ pub typ: CowStr<'a>,
108108+}
109109+110110+impl IntoStatic for JwtHeader<'_> {
111111+ type Output = JwtHeader<'static>;
112112+113113+ fn into_static(self) -> Self::Output {
114114+ JwtHeader {
115115+ alg: self.alg.into_static(),
116116+ typ: self.typ.into_static(),
117117+ }
118118+ }
119119+}
120120+121121+/// Service authentication claims.
122122+///
123123+/// These are the payload fields in a service auth JWT.
124124+#[derive(Debug, Clone, Serialize, Deserialize)]
125125+pub struct ServiceAuthClaims<'a> {
126126+ /// Issuer (user's DID)
127127+ #[serde(borrow)]
128128+ pub iss: Did<'a>,
129129+130130+ /// Audience (target service DID)
131131+ #[serde(borrow)]
132132+ pub aud: Did<'a>,
133133+134134+ /// Expiration time (unix timestamp)
135135+ pub exp: i64,
136136+137137+ /// Issued at (unix timestamp)
138138+ pub iat: i64,
139139+140140+ /// JWT ID (nonce for replay protection)
141141+ #[serde(borrow, skip_serializing_if = "Option::is_none")]
142142+ pub jti: Option<CowStr<'a>>,
143143+144144+ /// Lexicon method NSID (method binding)
145145+ #[serde(borrow, skip_serializing_if = "Option::is_none")]
146146+ pub lxm: Option<Nsid<'a>>,
147147+}
148148+149149+impl<'a> IntoStatic for ServiceAuthClaims<'a> {
150150+ type Output = ServiceAuthClaims<'static>;
151151+152152+ fn into_static(self) -> Self::Output {
153153+ ServiceAuthClaims {
154154+ iss: self.iss.into_static(),
155155+ aud: self.aud.into_static(),
156156+ exp: self.exp,
157157+ iat: self.iat,
158158+ jti: self.jti.map(|j| j.into_static()),
159159+ lxm: self.lxm.map(|l| l.into_static()),
160160+ }
161161+ }
162162+}
163163+164164+impl<'a> ServiceAuthClaims<'a> {
165165+ /// Validate the claims against expected values.
166166+ ///
167167+ /// Checks:
168168+ /// - Audience matches expected DID
169169+ /// - Token is not expired
170170+ pub fn validate(&self, expected_aud: &Did) -> Result<(), ServiceAuthError> {
171171+ // Check audience
172172+ if self.aud.as_str() != expected_aud.as_str() {
173173+ return Err(ServiceAuthError::AudienceMismatch {
174174+ expected: expected_aud.clone().into_static(),
175175+ actual: self.aud.clone().into_static(),
176176+ });
177177+ }
178178+179179+ // Check expiration
180180+ if self.is_expired() {
181181+ let now = chrono::Utc::now().timestamp();
182182+ return Err(ServiceAuthError::Expired { exp: self.exp, now });
183183+ }
184184+185185+ Ok(())
186186+ }
187187+188188+ /// Check if the token has expired.
189189+ pub fn is_expired(&self) -> bool {
190190+ let now = chrono::Utc::now().timestamp();
191191+ self.exp <= now
192192+ }
193193+194194+ /// Check if the method (lxm) matches the expected NSID.
195195+ pub fn check_method(&self, nsid: &Nsid) -> bool {
196196+ self.lxm
197197+ .as_ref()
198198+ .map(|lxm| lxm.as_str() == nsid.as_str())
199199+ .unwrap_or(false)
200200+ }
201201+202202+ /// Require that the method (lxm) matches the expected NSID.
203203+ pub fn require_method(&self, nsid: &Nsid) -> Result<(), ServiceAuthError> {
204204+ if !self.check_method(nsid) {
205205+ return Err(ServiceAuthError::MethodMismatch {
206206+ expected: nsid.clone().into_static(),
207207+ actual: self.lxm.as_ref().map(|l| l.clone().into_static()),
208208+ });
209209+ }
210210+ Ok(())
211211+ }
212212+}
213213+214214+/// Parsed JWT components.
215215+///
216216+/// This struct owns the decoded buffers and parsed components using ouroboros
217217+/// self-referencing. The header and claims borrow from their respective buffers.
218218+#[self_referencing]
219219+pub struct ParsedJwt {
220220+ /// Decoded header buffer (owned)
221221+ header_buf: Vec<u8>,
222222+ /// Decoded payload buffer (owned)
223223+ payload_buf: Vec<u8>,
224224+ /// Original token string for signing_input
225225+ token: String,
226226+ /// Signature bytes
227227+ signature: Vec<u8>,
228228+ /// Parsed header borrowing from header_buf
229229+ #[borrows(header_buf)]
230230+ #[covariant]
231231+ header: JwtHeader<'this>,
232232+ /// Parsed claims borrowing from payload_buf
233233+ #[borrows(payload_buf)]
234234+ #[covariant]
235235+ claims: ServiceAuthClaims<'this>,
236236+}
237237+238238+impl ParsedJwt {
239239+ /// Get the signing input (header.payload) for signature verification.
240240+ pub fn signing_input(&self) -> &[u8] {
241241+ self.with_token(|token| {
242242+ let dot_pos = token.find('.').unwrap();
243243+ let second_dot_pos = token[dot_pos + 1..].find('.').unwrap() + dot_pos + 1;
244244+ token[..second_dot_pos].as_bytes()
245245+ })
246246+ }
247247+248248+ /// Get a reference to the header.
249249+ pub fn header(&self) -> &JwtHeader<'_> {
250250+ self.borrow_header()
251251+ }
252252+253253+ /// Get a reference to the claims.
254254+ pub fn claims(&self) -> &ServiceAuthClaims<'_> {
255255+ self.borrow_claims()
256256+ }
257257+258258+ /// Get a reference to the signature.
259259+ pub fn signature(&self) -> &[u8] {
260260+ self.borrow_signature()
261261+ }
262262+263263+ /// Get owned header with 'static lifetime.
264264+ pub fn into_header(self) -> JwtHeader<'static> {
265265+ self.with_header(|header| header.clone().into_static())
266266+ }
267267+268268+ /// Get owned claims with 'static lifetime.
269269+ pub fn into_claims(self) -> ServiceAuthClaims<'static> {
270270+ self.with_claims(|claims| claims.clone().into_static())
271271+ }
272272+}
273273+274274+/// Parse a JWT token into its components without verifying the signature.
275275+///
276276+/// This extracts and decodes all JWT components. The header and claims are parsed
277277+/// and borrow from their respective owned buffers using ouroboros self-referencing.
278278+pub fn parse_jwt(token: &str) -> Result<ParsedJwt, ServiceAuthError> {
279279+ let parts: Vec<&str> = token.split('.').collect();
280280+ if parts.len() != 3 {
281281+ return Err(ServiceAuthError::MalformedToken(CowStr::new_static(
282282+ "JWT must have exactly 3 parts separated by dots",
283283+ )));
284284+ }
285285+286286+ let header_b64 = parts[0];
287287+ let payload_b64 = parts[1];
288288+ let signature_b64 = parts[2];
289289+290290+ // Decode all components
291291+ let header_buf = URL_SAFE_NO_PAD.decode(header_b64)?;
292292+ let payload_buf = URL_SAFE_NO_PAD.decode(payload_b64)?;
293293+ let signature = URL_SAFE_NO_PAD.decode(signature_b64)?;
294294+295295+ // Validate that buffers contain valid JSON for their types
296296+ // We parse once here to validate, then again in the builder (unavoidable with ouroboros)
297297+ let _header: JwtHeader = serde_json::from_slice(&header_buf)?;
298298+ let _claims: ServiceAuthClaims = serde_json::from_slice(&payload_buf)?;
299299+300300+ Ok(ParsedJwtBuilder {
301301+ header_buf,
302302+ payload_buf,
303303+ token: token.to_string(),
304304+ signature,
305305+ header_builder: |buf| {
306306+ // Safe: we validated this succeeds above
307307+ serde_json::from_slice(buf).expect("header was validated")
308308+ },
309309+ claims_builder: |buf| {
310310+ // Safe: we validated this succeeds above
311311+ serde_json::from_slice(buf).expect("claims were validated")
312312+ },
313313+ }
314314+ .build())
315315+}
316316+317317+/// Public key types for signature verification.
318318+#[derive(Debug, Clone)]
319319+pub enum PublicKey {
320320+ /// P-256 (ES256) public key
321321+ #[cfg(feature = "crypto-p256")]
322322+ P256(P256VerifyingKey),
323323+324324+ /// secp256k1 (ES256K) public key
325325+ #[cfg(feature = "crypto-k256")]
326326+ K256(K256VerifyingKey),
327327+}
328328+329329+impl PublicKey {
330330+ /// Create a P-256 public key from compressed or uncompressed bytes.
331331+ #[cfg(feature = "crypto-p256")]
332332+ pub fn from_p256_bytes(bytes: &[u8]) -> Result<Self, ServiceAuthError> {
333333+ let key = P256VerifyingKey::from_sec1_bytes(bytes).map_err(|e| {
334334+ ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!("invalid P-256 key: {}", e)))
335335+ })?;
336336+ Ok(PublicKey::P256(key))
337337+ }
338338+339339+ /// Create a secp256k1 public key from compressed or uncompressed bytes.
340340+ #[cfg(feature = "crypto-k256")]
341341+ pub fn from_k256_bytes(bytes: &[u8]) -> Result<Self, ServiceAuthError> {
342342+ let key = K256VerifyingKey::from_sec1_bytes(bytes).map_err(|e| {
343343+ ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!("invalid K-256 key: {}", e)))
344344+ })?;
345345+ Ok(PublicKey::K256(key))
346346+ }
347347+}
348348+349349+/// Verify a JWT signature using the provided public key.
350350+///
351351+/// The algorithm is determined by the JWT header and must match the public key type.
352352+pub fn verify_signature(
353353+ parsed: &ParsedJwt,
354354+ public_key: &PublicKey,
355355+) -> Result<(), ServiceAuthError> {
356356+ let alg = parsed.header().alg.as_str();
357357+ let signing_input = parsed.signing_input();
358358+ let signature = parsed.signature();
359359+360360+ match (alg, public_key) {
361361+ #[cfg(feature = "crypto-p256")]
362362+ ("ES256", PublicKey::P256(key)) => {
363363+ let sig = P256Signature::from_slice(signature).map_err(|e| {
364364+ ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!(
365365+ "invalid ES256 signature: {}",
366366+ e
367367+ )))
368368+ })?;
369369+ key.verify(signing_input, &sig)
370370+ .map_err(|_| ServiceAuthError::InvalidSignature)?;
371371+ Ok(())
372372+ }
373373+374374+ #[cfg(feature = "crypto-k256")]
375375+ ("ES256K", PublicKey::K256(key)) => {
376376+ let sig = K256Signature::from_slice(signature).map_err(|e| {
377377+ ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!(
378378+ "invalid ES256K signature: {}",
379379+ e
380380+ )))
381381+ })?;
382382+ key.verify(signing_input, &sig)
383383+ .map_err(|_| ServiceAuthError::InvalidSignature)?;
384384+ Ok(())
385385+ }
386386+387387+ _ => Err(ServiceAuthError::UnsupportedAlgorithm {
388388+ alg: SmolStr::new(alg),
389389+ }),
390390+ }
391391+}
392392+393393+/// Parse and verify a service auth JWT in one step, returning owned claims.
394394+///
395395+/// This is a convenience function that combines parsing and signature verification.
396396+pub fn verify_service_jwt(
397397+ token: &str,
398398+ public_key: &PublicKey,
399399+) -> Result<ServiceAuthClaims<'static>, ServiceAuthError> {
400400+ let parsed = parse_jwt(token)?;
401401+ verify_signature(&parsed, public_key)?;
402402+ Ok(parsed.into_claims())
403403+}
404404+405405+#[cfg(test)]
406406+mod tests {
407407+ use super::*;
408408+409409+ #[test]
410410+ fn test_parse_jwt_invalid_format() {
411411+ let result = parse_jwt("not.a.valid.jwt.with.too.many.parts");
412412+ assert!(matches!(result, Err(ServiceAuthError::MalformedToken(_))));
413413+ }
414414+415415+ #[test]
416416+ fn test_claims_expiration() {
417417+ let now = chrono::Utc::now().timestamp();
418418+ let expired_claims = ServiceAuthClaims {
419419+ iss: Did::new("did:plc:test").unwrap(),
420420+ aud: Did::new("did:web:example.com").unwrap(),
421421+ exp: now - 100,
422422+ iat: now - 200,
423423+ jti: None,
424424+ lxm: None,
425425+ };
426426+427427+ assert!(expired_claims.is_expired());
428428+429429+ let valid_claims = ServiceAuthClaims {
430430+ iss: Did::new("did:plc:test").unwrap(),
431431+ aud: Did::new("did:web:example.com").unwrap(),
432432+ exp: now + 100,
433433+ iat: now,
434434+ jti: None,
435435+ lxm: None,
436436+ };
437437+438438+ assert!(!valid_claims.is_expired());
439439+ }
440440+441441+ #[test]
442442+ fn test_audience_validation() {
443443+ let now = chrono::Utc::now().timestamp();
444444+ let claims = ServiceAuthClaims {
445445+ iss: Did::new("did:plc:test").unwrap(),
446446+ aud: Did::new("did:web:example.com").unwrap(),
447447+ exp: now + 100,
448448+ iat: now,
449449+ jti: None,
450450+ lxm: None,
451451+ };
452452+453453+ let expected_aud = Did::new("did:web:example.com").unwrap();
454454+ assert!(claims.validate(&expected_aud).is_ok());
455455+456456+ let wrong_aud = Did::new("did:web:wrong.com").unwrap();
457457+ assert!(matches!(
458458+ claims.validate(&wrong_aud),
459459+ Err(ServiceAuthError::AudienceMismatch { .. })
460460+ ));
461461+ }
462462+463463+ #[test]
464464+ fn test_method_check() {
465465+ let claims = ServiceAuthClaims {
466466+ iss: Did::new("did:plc:test").unwrap(),
467467+ aud: Did::new("did:web:example.com").unwrap(),
468468+ exp: chrono::Utc::now().timestamp() + 100,
469469+ iat: chrono::Utc::now().timestamp(),
470470+ jti: None,
471471+ lxm: Some(Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap()),
472472+ };
473473+474474+ let expected = Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap();
475475+ assert!(claims.check_method(&expected));
476476+477477+ let wrong = Nsid::new("app.bsky.feed.getTimeline").unwrap();
478478+ assert!(!claims.check_method(&wrong));
479479+ }
480480+}
-2
crates/jacquard-common/src/types.rs
···2424pub mod integer;
2525/// Language tag types per BCP 47
2626pub mod language;
2727-/// CID link wrapper for JSON serialization
2828-pub mod link;
2927/// Namespaced Identifier (NSID) types and validation
3028pub mod nsid;
3129/// Record key types and validation
···150150 Ok(Self(mime_type))
151151 }
152152153153+ /// Fallible constructor, validates, borrows from input if possible
154154+ pub fn new_cow(mime_type: CowStr<'m>) -> Result<MimeType<'m>, &'static str> {
155155+ Self::from_cowstr(mime_type)
156156+ }
157157+153158 /// Infallible constructor for trusted MIME type strings
154159 pub fn raw(mime_type: &'m str) -> Self {
155160 Self(CowStr::Borrowed(mime_type))
···190195 D: Deserializer<'de>,
191196 {
192197 let value = Deserialize::deserialize(deserializer)?;
193193- Self::new(value).map_err(D::Error::custom)
198198+ Self::new_cow(value).map_err(D::Error::custom)
194199 }
195200}
196201
+32-10
crates/jacquard-common/src/types/cid.rs
···22pub use cid::Cid as IpldCid;
33use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};
44use smol_str::ToSmolStr;
55-use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr};
55+use std::{convert::Infallible, fmt, ops::Deref, str::FromStr};
6677/// CID codec for AT Protocol (raw)
88pub const ATP_CID_CODEC: u64 = 0x55;
···1919/// This type supports both string and parsed IPLD forms, with string caching
2020/// for the parsed form to optimize serialization.
2121///
2222-/// Deserialization automatically detects the format (bytes trigger IPLD parsing).
2222+/// # Validation
2323+///
2424+/// String deserialization does NOT validate CIDs. This is intentional for performance:
2525+/// CID strings from AT Protocol endpoints are generally trustworthy, so validation
2626+/// is deferred until needed. Use `to_ipld()` to parse and validate, or `is_valid()`
2727+/// to check without parsing.
2828+///
2929+/// Byte deserialization (CBOR) parses immediately since the data is already in binary form.
2330#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2431pub enum Cid<'c> {
2532 /// Parsed IPLD CID with cached string representation
···100107 Cid::Str(cow_str) => cow_str.as_ref(),
101108 }
102109 }
110110+111111+ /// Check if the CID string is valid without parsing
112112+ ///
113113+ /// Returns `true` if the CID is already parsed (`Ipld` variant) or if
114114+ /// the string can be successfully parsed as an IPLD CID.
115115+ pub fn is_valid(&self) -> bool {
116116+ match self {
117117+ Cid::Ipld { .. } => true,
118118+ Cid::Str(s) => IpldCid::try_from(s.as_ref()).is_ok(),
119119+ }
120120+ }
103121}
104122105123impl std::fmt::Display for Cid<'_> {
···155173 where
156174 D: Deserializer<'de>,
157175 {
158158- struct StringOrBytes<T>(PhantomData<fn() -> T>);
176176+ struct CidVisitor;
159177160160- impl<'de, T> Visitor<'de> for StringOrBytes<T>
161161- where
162162- T: Deserialize<'de> + FromStr<Err = Infallible> + From<IpldCid>,
163163- {
164164- type Value = T;
178178+ impl<'de> Visitor<'de> for CidVisitor {
179179+ type Value = Cid<'de>;
165180166181 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
167182 formatter.write_str("either valid IPLD CID bytes or a str")
168183 }
169184185185+ fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
186186+ where
187187+ E: serde::de::Error,
188188+ {
189189+ Ok(Cid::str(v))
190190+ }
191191+170192 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
171193 where
172194 E: serde::de::Error,
···179201 E: serde::de::Error,
180202 {
181203 let hash = cid::multihash::Multihash::from_bytes(v).map_err(|e| E::custom(e))?;
182182- Ok(T::from(IpldCid::new_v1(ATP_CID_CODEC, hash)))
204204+ Ok(Cid::ipld(IpldCid::new_v1(ATP_CID_CODEC, hash)))
183205 }
184206 }
185207186186- deserializer.deserialize_any(StringOrBytes(PhantomData))
208208+ deserializer.deserialize_any(CidVisitor)
187209 }
188210}
189211
+23-1
crates/jacquard-common/src/types/did.rs
···5454 }
5555 }
56565757+ /// Fallible constructor, validates, borrows from input if possible
5858+ ///
5959+ /// May allocate for a long DID with an at:// prefix, otherwise borrows.
6060+ pub fn new_cow(did: CowStr<'d>) -> Result<Self, AtStrError> {
6161+ let did = if let Some(did) = did.strip_prefix("at://") {
6262+ CowStr::copy_from_str(did)
6363+ } else {
6464+ did
6565+ };
6666+ if did.len() > 2048 {
6767+ Err(AtStrError::too_long("did", &did, 2048, did.len()))
6868+ } else if !DID_REGEX.is_match(&did) {
6969+ Err(AtStrError::regex(
7070+ "did",
7171+ &did,
7272+ SmolStr::new_static("invalid"),
7373+ ))
7474+ } else {
7575+ Ok(Self(did))
7676+ }
7777+ }
7878+5779 /// Fallible constructor, validates, takes ownership
5880 pub fn new_owned(did: impl AsRef<str>) -> Result<Self, AtStrError> {
5981 let did = did.as_ref();
···144166 D: Deserializer<'de>,
145167 {
146168 let value = Deserialize::deserialize(deserializer)?;
147147- Self::new(value).map_err(D::Error::custom)
169169+ Self::new_cow(value).map_err(D::Error::custom)
148170 }
149171}
150172
+29-1
crates/jacquard-common/src/types/handle.rs
···108108 Ok(Self(CowStr::new_static(handle)))
109109 }
110110 }
111111+112112+ /// Fallible constructor, validates, borrows from input if possible
113113+ ///
114114+ /// May allocate for a long handle with an at:// or @ prefix, otherwise borrows.
115115+ /// Accepts (and strips) preceding '@' or 'at://' if present
116116+ pub fn new_cow(handle: CowStr<'h>) -> Result<Self, AtStrError> {
117117+ let handle = if let Some(stripped) = handle.strip_prefix("at://") {
118118+ CowStr::copy_from_str(stripped)
119119+ } else if let Some(stripped) = handle.strip_prefix('@') {
120120+ CowStr::copy_from_str(stripped)
121121+ } else {
122122+ handle
123123+ };
124124+ if handle.len() > 253 {
125125+ Err(AtStrError::too_long("handle", &handle, 253, handle.len()))
126126+ } else if !HANDLE_REGEX.is_match(&handle) {
127127+ Err(AtStrError::regex(
128128+ "handle",
129129+ &handle,
130130+ SmolStr::new_static("invalid"),
131131+ ))
132132+ } else if ends_with(&handle, DISALLOWED_TLDS) {
133133+ Err(AtStrError::disallowed("handle", &handle, DISALLOWED_TLDS))
134134+ } else {
135135+ Ok(Self(handle))
136136+ }
137137+ }
138138+111139 /// Infallible constructor for when you *know* the string is a valid handle.
112140 /// Will panic on invalid handles. If you're manually decoding atproto records
113141 /// or API values you know are valid (rather than using serde), this is the one to use.
···179207 D: Deserializer<'de>,
180208 {
181209 let value = Deserialize::deserialize(deserializer)?;
182182- Self::new(value).map_err(D::Error::custom)
210210+ Self::new_cow(value).map_err(D::Error::custom)
183211 }
184212}
185213
+9
crates/jacquard-common/src/types/ident.rs
···5454 }
5555 }
56565757+ /// Fallible constructor, validates, borrows from input if possible
5858+ pub fn new_cow(ident: CowStr<'i>) -> Result<Self, AtStrError> {
5959+ if let Ok(did) = Did::new_cow(ident.clone()) {
6060+ Ok(AtIdentifier::Did(did))
6161+ } else {
6262+ Ok(AtIdentifier::Handle(Handle::new_cow(ident)?))
6363+ }
6464+ }
6565+5766 /// Infallible constructor for when you *know* the string is a valid identifier.
5867 /// Will panic on invalid identifiers. If you're manually decoding atproto records
5968 /// or API values you know are valid (rather than using serde), this is the one to use.
···8181 }
8282 }
83838484+ /// Fallible constructor, validates, borrows from input if possible
8585+ pub fn new_cow(nsid: CowStr<'n>) -> Result<Self, AtStrError> {
8686+ if nsid.len() > 317 {
8787+ Err(AtStrError::too_long("nsid", &nsid, 317, nsid.len()))
8888+ } else if !NSID_REGEX.is_match(&nsid) {
8989+ Err(AtStrError::regex(
9090+ "nsid",
9191+ &nsid,
9292+ SmolStr::new_static("invalid"),
9393+ ))
9494+ } else {
9595+ Ok(Self(nsid))
9696+ }
9797+ }
9898+8499 /// Infallible constructor for when you *know* the string is a valid NSID.
85100 /// Will panic on invalid NSIDs. If you're manually decoding atproto records
86101 /// or API values you know are valid (rather than using serde), this is the one to use.
···148163 where
149164 D: Deserializer<'de>,
150165 {
151151- let value: &str = Deserialize::deserialize(deserializer)?;
152152- Self::new(value).map_err(D::Error::custom)
166166+ let value = Deserialize::deserialize(deserializer)?;
167167+ Self::new_cow(value).map_err(D::Error::custom)
153168 }
154169}
155170
+17-2
crates/jacquard-common/src/types/recordkey.rs
···137137 }
138138 }
139139140140+ /// Fallible constructor, validates, borrows from input if possible
141141+ pub fn new_cow(rkey: CowStr<'r>) -> Result<Self, AtStrError> {
142142+ if [".", ".."].contains(&rkey.as_ref()) {
143143+ Err(AtStrError::disallowed("record-key", &rkey, &[".", ".."]))
144144+ } else if !RKEY_REGEX.is_match(&rkey) {
145145+ Err(AtStrError::regex(
146146+ "record-key",
147147+ &rkey,
148148+ SmolStr::new_static("doesn't match 'any' schema"),
149149+ ))
150150+ } else {
151151+ Ok(Self(rkey))
152152+ }
153153+ }
154154+140155 /// Infallible constructor for when you *know* the string is a valid rkey.
141156 /// Will panic on invalid rkeys. If you're manually decoding atproto records
142157 /// or API values you know are valid (rather than using serde), this is the one to use.
···200215 where
201216 D: Deserializer<'de>,
202217 {
203203- let value: &str = Deserialize::deserialize(deserializer)?;
204204- Self::new(value).map_err(D::Error::custom)
218218+ let value = Deserialize::deserialize(deserializer)?;
219219+ Self::new_cow(value).map_err(D::Error::custom)
205220 }
206221}
207222
+30-7
crates/jacquard-common/src/types/uri.rs
···11-use serde::{Deserialize, Deserializer, Serialize, Serializer};
22-use smol_str::ToSmolStr;
33-use url::Url;
44-51use crate::{
62 CowStr, IntoStatic,
73 types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError},
84};
55+use serde::{Deserialize, Deserializer, Serialize, Serializer};
66+use smol_str::ToSmolStr;
77+use std::str::FromStr;
88+use url::Url;
991010/// Generic URI with type-specific parsing
1111///
···5555 } else if uri.starts_with("wss://") {
5656 Ok(Uri::Https(Url::parse(uri)?))
5757 } else if uri.starts_with("ipld://") {
5858- Ok(Uri::Cid(Cid::new(uri.as_bytes())?))
5858+ Ok(Uri::Cid(
5959+ Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
6060+ ))
5961 } else {
6062 Ok(Uri::Any(CowStr::Borrowed(uri)))
6163 }
···7375 } else if uri.starts_with("wss://") {
7476 Ok(Uri::Https(Url::parse(uri)?))
7577 } else if uri.starts_with("ipld://") {
7676- Ok(Uri::Cid(Cid::new_owned(uri.as_bytes())?))
7878+ Ok(Uri::Cid(
7979+ Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
8080+ ))
7781 } else {
7882 Ok(Uri::Any(CowStr::Owned(uri.to_smolstr())))
7983 }
8084 }
81858686+ /// Parse a URI from a CowStr, borrowing where possible
8787+ pub fn new_cow(uri: CowStr<'u>) -> Result<Self, UriParseError> {
8888+ if uri.starts_with("did:") {
8989+ Ok(Uri::Did(Did::new_cow(uri)?))
9090+ } else if uri.starts_with("at://") {
9191+ Ok(Uri::At(AtUri::new_cow(uri)?))
9292+ } else if uri.starts_with("https://") {
9393+ Ok(Uri::Https(Url::parse(uri.as_ref())?))
9494+ } else if uri.starts_with("wss://") {
9595+ Ok(Uri::Https(Url::parse(uri.as_ref())?))
9696+ } else if uri.starts_with("ipld://") {
9797+ Ok(Uri::Cid(
9898+ Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(),
9999+ ))
100100+ } else {
101101+ Ok(Uri::Any(uri))
102102+ }
103103+ }
104104+82105 /// Get the URI as a string slice
83106 pub fn as_str(&self) -> &str {
84107 match self {
···111134 {
112135 use serde::de::Error;
113136 let value = Deserialize::deserialize(deserializer)?;
114114- Self::new(value).map_err(D::Error::custom)
137137+ Self::new_cow(value).map_err(D::Error::custom)
115138 }
116139}
117140