diff --git a/parakeet-db/src/at_uri_util.rs b/parakeet-db/src/at_uri_util.rs new file mode 100644 index 00000000..4693f869 --- /dev/null +++ b/parakeet-db/src/at_uri_util.rs @@ -0,0 +1,147 @@ +/// AT URI utilities for parsing and reconstructing AT URIs +/// +/// AT URIs have the format: at://did:plc:xyz/collection/rkey +/// +/// After optimization, we store: +/// - actor_id (INTEGER FK to actors.id) +/// - collection_id (SMALLINT FK to record_types.id) +/// - rkey (TEXT) +/// +/// These utilities help convert between the formats. + +/// Parse an AT URI into its components +/// +/// # Arguments +/// * `at_uri` - The full AT URI (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") +/// +/// # Returns +/// * `Some((did, collection, rkey))` - The three components +/// * `None` - If the URI is malformed +pub fn parse_at_uri(at_uri: &str) -> Option<(&str, &str, &str)> { + // Expected format: at://did:plc:xyz/app.bsky.feed.post/rkey + let without_prefix = at_uri.strip_prefix("at://")?; + + let mut parts = without_prefix.splitn(3, '/'); + let did = parts.next()?; + let collection = parts.next()?; + let rkey = parts.next()?; + + // Basic validation + if did.is_empty() || collection.is_empty() || rkey.is_empty() { + return None; + } + + Some((did, collection, rkey)) +} + +/// Reconstruct an AT URI from its components +/// +/// # Arguments +/// * `did` - The DID (e.g., "did:plc:xyz") +/// * `collection` - The collection name (e.g., "app.bsky.feed.post") +/// * `rkey` - The record key +/// +/// # Returns +/// * The full AT URI +#[inline] +pub fn build_at_uri(did: &str, collection: &str, rkey: &str) -> String { + format!("at://{}/{}/{}", did, collection, rkey) +} + +/// Extract just the rkey from an AT URI +/// +/// # Arguments +/// * `at_uri` - The full AT URI +/// +/// # Returns +/// * `Some(rkey)` - The record key +/// * `None` - If the URI is malformed +#[inline] +pub fn extract_rkey(at_uri: &str) -> Option<&str> { + parse_at_uri(at_uri).map(|(_, _, rkey)| rkey) +} + +/// Extract just the collection from an AT URI +/// +/// # Arguments +/// * `at_uri` - The full AT URI +/// +/// # Returns +/// * `Some(collection)` - The collection name +/// * `None` - If the URI is malformed +#[inline] +pub fn extract_collection(at_uri: &str) -> Option<&str> { + parse_at_uri(at_uri).map(|(_, collection, _)| collection) +} + +/// Extract just the DID from an AT URI +/// +/// # Arguments +/// * `at_uri` - The full AT URI +/// +/// # Returns +/// * `Some(did)` - The DID +/// * `None` - If the URI is malformed +#[inline] +pub fn extract_did(at_uri: &str) -> Option<&str> { + parse_at_uri(at_uri).map(|(did, _, _)| did) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_URI: &str = "at://did:plc:xyz123abc/app.bsky.feed.post/3k2abc123"; + + #[test] + fn test_parse_at_uri() { + let (did, collection, rkey) = parse_at_uri(SAMPLE_URI).unwrap(); + assert_eq!(did, "did:plc:xyz123abc"); + assert_eq!(collection, "app.bsky.feed.post"); + assert_eq!(rkey, "3k2abc123"); + } + + #[test] + fn test_build_at_uri() { + let uri = build_at_uri("did:plc:xyz123abc", "app.bsky.feed.post", "3k2abc123"); + assert_eq!(uri, SAMPLE_URI); + } + + #[test] + fn test_roundtrip() { + let (did, collection, rkey) = parse_at_uri(SAMPLE_URI).unwrap(); + let reconstructed = build_at_uri(did, collection, rkey); + assert_eq!(reconstructed, SAMPLE_URI); + } + + #[test] + fn test_extract_rkey() { + assert_eq!(extract_rkey(SAMPLE_URI), Some("3k2abc123")); + } + + #[test] + fn test_extract_collection() { + assert_eq!(extract_collection(SAMPLE_URI), Some("app.bsky.feed.post")); + } + + #[test] + fn test_extract_did() { + assert_eq!(extract_did(SAMPLE_URI), Some("did:plc:xyz123abc")); + } + + #[test] + fn test_invalid_uris() { + assert!(parse_at_uri("not-an-at-uri").is_none()); + assert!(parse_at_uri("at://").is_none()); + assert!(parse_at_uri("at://did/collection").is_none()); // Missing rkey + assert!(parse_at_uri("https://example.com").is_none()); + } + + #[test] + fn test_rkey_with_slashes() { + // Some rkeys might contain encoded slashes or special chars + let uri = "at://did:plc:abc/app.bsky.feed.post/rkey-with-dash"; + let (_, _, rkey) = parse_at_uri(uri).unwrap(); + assert_eq!(rkey, "rkey-with-dash"); + } +} diff --git a/parakeet-db/src/cid_util.rs b/parakeet-db/src/cid_util.rs new file mode 100644 index 00000000..51eaf4ae --- /dev/null +++ b/parakeet-db/src/cid_util.rs @@ -0,0 +1,148 @@ +/// CID optimization utilities for database storage +/// +/// All CIDs in our database are CIDv1 with identical structure: +/// - 0x01: CIDv1 version +/// - 0x71: Raw codec +/// - 0x1220: SHA-256 multihash header (0x12=SHA256, 0x20=32 bytes) +/// - 32 bytes: SHA-256 digest +/// +/// Total: 36 bytes +/// +/// We store only the 32-byte digest in the database, stripping the constant +/// 4-byte prefix (0x01711220) to save space. + +/// Constant 4-byte CIDv1 header that all our CIDs have +/// 0x01 (CIDv1) + 0x71 (raw codec) + 0x1220 (SHA-256 multihash header) +pub const CID_HEADER: [u8; 4] = [0x01, 0x71, 0x12, 0x20]; + +/// Strip the CIDv1 header from a binary CID, returning only the 32-byte digest +/// +/// # Arguments +/// * `cid_bytes` - Full CID bytes (must be 36 bytes with correct header) +/// +/// # Returns +/// * `Some(&[u8])` - Slice to the 32-byte digest +/// * `None` - If the CID is not 36 bytes or doesn't have the expected header +#[inline] +pub fn cid_to_digest(cid_bytes: &[u8]) -> Option<&[u8]> { + if cid_bytes.len() == 36 && cid_bytes.starts_with(&CID_HEADER) { + Some(&cid_bytes[4..]) + } else { + None + } +} + +/// Strip the CIDv1 header from a binary CID, returning owned 32-byte digest +/// +/// # Arguments +/// * `cid_bytes` - Full CID bytes (must be 36 bytes with correct header) +/// +/// # Returns +/// * `Some(Vec)` - The 32-byte digest +/// * `None` - If the CID is invalid +#[inline] +pub fn cid_to_digest_owned(cid_bytes: &[u8]) -> Option> { + cid_to_digest(cid_bytes).map(|d| d.to_vec()) +} + +/// Reconstruct a full CID from a 32-byte digest +/// +/// # Arguments +/// * `digest` - The 32-byte SHA-256 digest +/// +/// # Returns +/// * `Some(Vec)` - The full 36-byte CID +/// * `None` - If the digest is not 32 bytes +#[inline] +pub fn digest_to_cid(digest: &[u8]) -> Option> { + if digest.len() == 32 { + let mut cid = Vec::with_capacity(36); + cid.extend_from_slice(&CID_HEADER); + cid.extend_from_slice(digest); + Some(cid) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_header_constant() { + assert_eq!(CID_HEADER, [0x01, 0x71, 0x12, 0x20]); + } + + #[test] + fn test_cid_to_digest() { + // Create a valid CID bytes (36 bytes) + let mut cid_bytes = CID_HEADER.to_vec(); + cid_bytes.extend(vec![0xab; 32]); // 32-byte digest of all 0xab + + // Convert to digest + let digest = cid_to_digest(&cid_bytes).unwrap(); + assert_eq!(digest.len(), 32); + assert_eq!(digest, &vec![0xab; 32][..]); + } + + #[test] + fn test_cid_to_digest_owned() { + let mut cid_bytes = CID_HEADER.to_vec(); + cid_bytes.extend(vec![0xcd; 32]); + + let digest = cid_to_digest_owned(&cid_bytes).unwrap(); + assert_eq!(digest.len(), 32); + assert_eq!(digest, vec![0xcd; 32]); + } + + #[test] + fn test_digest_to_cid() { + let digest = vec![0xab; 32]; + + // Convert to CID + let cid = digest_to_cid(&digest).unwrap(); + assert_eq!(cid.len(), 36); + assert_eq!(&cid[0..4], &CID_HEADER); + assert_eq!(&cid[4..], &digest[..]); + } + + #[test] + fn test_roundtrip() { + // Create a fake but valid CID bytes (36 bytes) + let mut cid_bytes = CID_HEADER.to_vec(); + cid_bytes.extend(vec![0xef; 32]); + + // Convert to digest and back + let digest = cid_to_digest(&cid_bytes).unwrap(); + let reconstructed = digest_to_cid(digest).unwrap(); + + assert_eq!(reconstructed, cid_bytes); + } + + #[test] + fn test_invalid_cid_length() { + let short_cid = vec![0x01, 0x71, 0x12]; + assert!(cid_to_digest(&short_cid).is_none()); + + let long_cid = vec![0x01; 100]; + assert!(cid_to_digest(&long_cid).is_none()); + } + + #[test] + fn test_invalid_cid_header() { + let mut bad_cid = vec![0x00, 0x00, 0x00, 0x00]; // Wrong header + bad_cid.extend(vec![0xab; 32]); + + assert!(cid_to_digest(&bad_cid).is_none()); + } + + #[test] + fn test_invalid_digest_length() { + let short_digest = vec![0xab; 16]; + assert!(digest_to_cid(&short_digest).is_none()); + + let long_digest = vec![0xab; 64]; + assert!(digest_to_cid(&long_digest).is_none()); + } +} diff --git a/parakeet-db/src/lib.rs b/parakeet-db/src/lib.rs index fe4bb223..b220b9da 100644 --- a/parakeet-db/src/lib.rs +++ b/parakeet-db/src/lib.rs @@ -1,5 +1,7 @@ pub mod actor_cache; pub mod allowlist; +pub mod at_uri_util; +pub mod cid_util; pub mod models; pub mod schema; pub mod types;