forked from
parakeet.at/parakeet
Rust AppView - highly experimental!
1/// CID optimization utilities for database storage
2///
3/// AT Protocol uses two types of CIDs, both CIDv1 with SHA-256:
4///
5/// 1. **Blob CIDs** (images, videos, avatars, banners):
6/// - 0x01: CIDv1 version
7/// - 0x55: Raw codec (multicodec for raw binary data)
8/// - 0x1220: SHA-256 multihash header (0x12=SHA256, 0x20=32 bytes)
9/// - 32 bytes: SHA-256 digest
10/// - String format: "bafkrei..." (base32-encoded)
11///
12/// 2. **Record CIDs** (posts, profiles, lists, etc.):
13/// - 0x01: CIDv1 version
14/// - 0x71: DAG-CBOR codec (multicodec for CBOR-encoded DAG nodes)
15/// - 0x1220: SHA-256 multihash header (0x12=SHA256, 0x20=32 bytes)
16/// - 32 bytes: SHA-256 digest
17/// - String format: "bafyrei..." (base32-encoded)
18///
19/// Total: 36 bytes for both types
20///
21/// We store only the 32-byte digest in the database, stripping the 4-byte
22/// prefix. When reconstructing, we use the appropriate header based on context
23/// (blobs use raw codec, records use dag-cbor codec).
24///
25/// Reference: https://atproto.com/specs/data-model
26/// CIDv1 header for blob CIDs (images, videos, etc.)
27/// 0x01 (CIDv1) + 0x55 (raw codec) + 0x1220 (SHA-256 multihash header)
28pub const BLOB_CID_HEADER: [u8; 4] = [0x01, 0x55, 0x12, 0x20];
29
30/// CIDv1 header for record CIDs (posts, profiles, lists, etc.)
31/// 0x01 (CIDv1) + 0x71 (dag-cbor codec) + 0x1220 (SHA-256 multihash header)
32pub const RECORD_CID_HEADER: [u8; 4] = [0x01, 0x71, 0x12, 0x20];
33
34/// Strip the CIDv1 header from a binary CID, returning only the 32-byte digest
35///
36/// Accepts both blob CIDs (0x55 raw codec) and record CIDs (0x71 dag-cbor codec).
37///
38/// # Arguments
39/// * `cid_bytes` - Full CID bytes (must be 36 bytes with valid CIDv1 header)
40///
41/// # Returns
42/// * `Some(&[u8])` - Slice to the 32-byte digest
43/// * `None` - If the CID is not 36 bytes or doesn't have a valid AT Protocol header
44#[inline]
45pub fn cid_to_digest(cid_bytes: &[u8]) -> Option<&[u8]> {
46 if cid_bytes.len() == 36 {
47 // Check if it's a valid AT Protocol CID (blob or record)
48 if cid_bytes.starts_with(&BLOB_CID_HEADER) || cid_bytes.starts_with(&RECORD_CID_HEADER) {
49 return Some(&cid_bytes[4..]);
50 }
51 }
52 None
53}
54
55/// Strip the CIDv1 header from a binary CID, returning owned 32-byte digest
56///
57/// # Arguments
58/// * `cid_bytes` - Full CID bytes (must be 36 bytes with correct header)
59///
60/// # Returns
61/// * `Some(Vec<u8>)` - The 32-byte digest
62/// * `None` - If the CID is invalid
63#[inline]
64pub fn cid_to_digest_owned(cid_bytes: &[u8]) -> Option<Vec<u8>> {
65 cid_to_digest(cid_bytes).map(|d| d.to_vec())
66}
67
68/// Reconstruct a full blob CID from a 32-byte digest
69///
70/// Creates a CID with raw codec (0x55) suitable for blobs (images, videos, etc.).
71/// Produces CIDs that start with "bafkrei..." when base32-encoded.
72///
73/// # Arguments
74/// * `digest` - The 32-byte SHA-256 digest
75///
76/// # Returns
77/// * `Some(Vec<u8>)` - The full 36-byte blob CID
78/// * `None` - If the digest is not 32 bytes
79#[inline]
80pub fn digest_to_blob_cid(digest: &[u8]) -> Option<Vec<u8>> {
81 if digest.len() == 32 {
82 let mut cid = Vec::with_capacity(36);
83 cid.extend_from_slice(&BLOB_CID_HEADER);
84 cid.extend_from_slice(digest);
85 Some(cid)
86 } else {
87 None
88 }
89}
90
91/// Reconstruct a full record CID from a 32-byte digest
92///
93/// Creates a CID with dag-cbor codec (0x71) suitable for records (posts, profiles, etc.).
94/// Produces CIDs that start with "bafyrei..." when base32-encoded.
95///
96/// # Arguments
97/// * `digest` - The 32-byte SHA-256 digest
98///
99/// # Returns
100/// * `Some(Vec<u8>)` - The full 36-byte record CID
101/// * `None` - If the digest is not 32 bytes
102#[inline]
103pub fn digest_to_record_cid(digest: &[u8]) -> Option<Vec<u8>> {
104 if digest.len() == 32 {
105 let mut cid = Vec::with_capacity(36);
106 cid.extend_from_slice(&RECORD_CID_HEADER);
107 cid.extend_from_slice(digest);
108 Some(cid)
109 } else {
110 None
111 }
112}
113/// Convert a 32-byte digest to a base32 blob CID string
114///
115/// Creates a CID string with raw codec (0x55) suitable for blobs.
116/// Produces strings starting with "bafkrei...".
117///
118/// # Arguments
119/// * `digest` - The 32-byte SHA-256 digest
120///
121/// # Returns
122/// * `Some(String)` - Base32-encoded blob CID string (e.g., "bafkrei...")
123/// * `None` - If the digest is not 32 bytes
124pub fn digest_to_blob_cid_string(digest: &[u8]) -> Option<String> {
125 let full_cid = digest_to_blob_cid(digest)?;
126 // Use multibase to encode directly without parsing
127 // CIDv1 uses base32 (lowercase) encoding
128 Some(multibase::encode(multibase::Base::Base32Lower, &full_cid))
129}
130
131/// Convert a 32-byte digest to a base32 record CID string
132///
133/// Creates a CID string with dag-cbor codec (0x71) suitable for records.
134/// Produces strings starting with "bafyrei...".
135///
136/// # Arguments
137/// * `digest` - The 32-byte SHA-256 digest
138///
139/// # Returns
140/// * `Some(String)` - Base32-encoded record CID string (e.g., "bafyrei...")
141/// * `None` - If the digest is not 32 bytes
142pub fn digest_to_record_cid_string(digest: &[u8]) -> Option<String> {
143 let full_cid = digest_to_record_cid(digest)?;
144 // Use multibase to encode directly without parsing
145 // CIDv1 uses base32 (lowercase) encoding
146 Some(multibase::encode(multibase::Base::Base32Lower, &full_cid))
147}
148
149/// Generate a deterministic CID digest for any record type
150///
151/// Computes SHA-256("parakeet:{record_type}:{actor_id}:{rkey}")
152#[inline]
153fn generate_cid_digest(record_type: &str, actor_id: i32, rkey: i64) -> Vec<u8> {
154 use sha2::{Digest, Sha256};
155 let data = format!("parakeet:{}:{}:{}", record_type, actor_id, rkey);
156 Sha256::digest(data.as_bytes()).to_vec()
157}
158
159/// Generate a deterministic digest for a like record
160pub fn generate_like_cid_digest(actor_id: i32, rkey: i64) -> Vec<u8> {
161 generate_cid_digest("like", actor_id, rkey)
162}
163
164/// Generate a synthetic CID string for a like record (returns "bafyrei...")
165pub fn like_cid_string(actor_id: i32, rkey: i64) -> String {
166 let digest = generate_like_cid_digest(actor_id, rkey);
167 digest_to_record_cid_string(&digest)
168 .unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
169}
170
171/// Generate a deterministic digest for a repost record
172pub fn generate_repost_cid_digest(actor_id: i32, rkey: i64) -> Vec<u8> {
173 generate_cid_digest("repost", actor_id, rkey)
174}
175
176/// Generate a synthetic CID string for a repost record (returns "bafyrei...")
177pub fn repost_cid_string(actor_id: i32, rkey: i64) -> String {
178 let digest = generate_repost_cid_digest(actor_id, rkey);
179 digest_to_record_cid_string(&digest)
180 .unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
181}
182
183/// Generate a deterministic digest for a follow record
184pub fn generate_follow_cid_digest(actor_id: i32, rkey: i64) -> Vec<u8> {
185 generate_cid_digest("follow", actor_id, rkey)
186}
187
188/// Generate a synthetic CID string for a follow record (returns "bafyrei...")
189pub fn follow_cid_string(actor_id: i32, rkey: i64) -> String {
190 let digest = generate_follow_cid_digest(actor_id, rkey);
191 digest_to_record_cid_string(&digest)
192 .unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
193}
194
195/// Generate a deterministic digest for a block record
196pub fn generate_block_cid_digest(actor_id: i32, rkey: i64) -> Vec<u8> {
197 generate_cid_digest("block", actor_id, rkey)
198}
199
200/// Generate a synthetic CID string for a block record (returns "bafyrei...")
201pub fn block_cid_string(actor_id: i32, rkey: i64) -> String {
202 let digest = generate_block_cid_digest(actor_id, rkey);
203 digest_to_record_cid_string(&digest)
204 .unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
205}
206
207/// Generate a deterministic digest for a post record
208///
209/// Note: This is a synthetic CID based on (actor_id, rkey) for AppView purposes.
210pub fn generate_post_cid_digest(actor_id: i32, rkey: i64) -> Vec<u8> {
211 generate_cid_digest("post", actor_id, rkey)
212}
213
214/// Generate a synthetic CID string for a post record (returns "bafyrei...")
215pub fn post_cid_string(actor_id: i32, rkey: i64) -> String {
216 let digest = generate_post_cid_digest(actor_id, rkey);
217 digest_to_record_cid_string(&digest)
218 .unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn test_header_constants() {
227 assert_eq!(BLOB_CID_HEADER, [0x01, 0x55, 0x12, 0x20]);
228 assert_eq!(RECORD_CID_HEADER, [0x01, 0x71, 0x12, 0x20]);
229 }
230
231 #[test]
232 fn test_cid_to_digest_blob() {
233 // Test with blob CID (0x55 raw codec)
234 let mut blob_cid = BLOB_CID_HEADER.to_vec();
235 blob_cid.extend(vec![0xab; 32]);
236
237 let digest = cid_to_digest(&blob_cid).unwrap();
238 assert_eq!(digest.len(), 32);
239 assert_eq!(digest, &vec![0xab; 32][..]);
240 }
241
242 #[test]
243 fn test_cid_to_digest_record() {
244 // Test with record CID (0x71 dag-cbor codec)
245 let mut record_cid = RECORD_CID_HEADER.to_vec();
246 record_cid.extend(vec![0xab; 32]);
247
248 let digest = cid_to_digest(&record_cid).unwrap();
249 assert_eq!(digest.len(), 32);
250 assert_eq!(digest, &vec![0xab; 32][..]);
251 }
252
253 #[test]
254 fn test_cid_to_digest_owned_both_types() {
255 // Test with blob CID
256 let mut blob_cid = BLOB_CID_HEADER.to_vec();
257 blob_cid.extend(vec![0xcd; 32]);
258 let digest = cid_to_digest_owned(&blob_cid).unwrap();
259 assert_eq!(digest, vec![0xcd; 32]);
260
261 // Test with record CID
262 let mut record_cid = RECORD_CID_HEADER.to_vec();
263 record_cid.extend(vec![0xef; 32]);
264 let digest = cid_to_digest_owned(&record_cid).unwrap();
265 assert_eq!(digest, vec![0xef; 32]);
266 }
267
268 #[test]
269 fn test_digest_to_blob_cid() {
270 let digest = vec![0xab; 32];
271
272 let cid = digest_to_blob_cid(&digest).unwrap();
273 assert_eq!(cid.len(), 36);
274 assert_eq!(&cid[0..4], &BLOB_CID_HEADER);
275 assert_eq!(&cid[4..], &digest[..]);
276 }
277
278 #[test]
279 fn test_digest_to_record_cid() {
280 let digest = vec![0xcd; 32];
281
282 let cid = digest_to_record_cid(&digest).unwrap();
283 assert_eq!(cid.len(), 36);
284 assert_eq!(&cid[0..4], &RECORD_CID_HEADER);
285 assert_eq!(&cid[4..], &digest[..]);
286 }
287
288 #[test]
289 fn test_roundtrip_blob() {
290 let mut blob_cid = BLOB_CID_HEADER.to_vec();
291 blob_cid.extend(vec![0xef; 32]);
292
293 let digest = cid_to_digest(&blob_cid).unwrap();
294 let reconstructed = digest_to_blob_cid(digest).unwrap();
295
296 assert_eq!(reconstructed, blob_cid);
297 }
298
299 #[test]
300 fn test_roundtrip_record() {
301 let mut record_cid = RECORD_CID_HEADER.to_vec();
302 record_cid.extend(vec![0x12; 32]);
303
304 let digest = cid_to_digest(&record_cid).unwrap();
305 let reconstructed = digest_to_record_cid(digest).unwrap();
306
307 assert_eq!(reconstructed, record_cid);
308 }
309
310 #[test]
311 fn test_invalid_cid_length() {
312 let short_cid = vec![0x01, 0x71, 0x12];
313 assert!(cid_to_digest(&short_cid).is_none());
314
315 let long_cid = vec![0x01; 100];
316 assert!(cid_to_digest(&long_cid).is_none());
317 }
318
319 #[test]
320 fn test_invalid_cid_header() {
321 let mut bad_cid = vec![0x00, 0x00, 0x00, 0x00]; // Wrong header
322 bad_cid.extend(vec![0xab; 32]);
323
324 assert!(cid_to_digest(&bad_cid).is_none());
325 }
326
327 #[test]
328 fn test_invalid_digest_length() {
329 let short_digest = vec![0xab; 16];
330 assert!(digest_to_record_cid(&short_digest).is_none());
331 assert!(digest_to_blob_cid(&short_digest).is_none());
332
333 let long_digest = vec![0xab; 64];
334 assert!(digest_to_record_cid(&long_digest).is_none());
335 assert!(digest_to_blob_cid(&long_digest).is_none());
336 }
337
338 #[test]
339 fn test_digest_to_blob_cid_string_matches_cid_crate() {
340 let digest = vec![0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
341 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
342 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
343 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, 0x18];
344
345 let our_result = digest_to_blob_cid_string(&digest).unwrap();
346
347 let full_cid = digest_to_blob_cid(&digest).unwrap();
348 let cid_obj = cid::Cid::try_from(full_cid.as_slice()).unwrap();
349 let cid_result = cid_obj.to_string();
350
351 assert_eq!(our_result, cid_result);
352 assert!(our_result.starts_with("bafkrei"), "Blob CIDs should start with bafkrei");
353 }
354
355 #[test]
356 fn test_digest_to_record_cid_string_matches_cid_crate() {
357 let digest = vec![0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0,
358 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
359 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
360 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0x07, 0x18];
361
362 let our_result = digest_to_record_cid_string(&digest).unwrap();
363
364 let full_cid = digest_to_record_cid(&digest).unwrap();
365 let cid_obj = cid::Cid::try_from(full_cid.as_slice()).unwrap();
366 let cid_result = cid_obj.to_string();
367
368 assert_eq!(our_result, cid_result);
369 assert!(our_result.starts_with("bafyrei"), "Record CIDs should start with bafyrei");
370 }
371
372 #[test]
373 fn test_blob_cid_string_real_example() {
374 let digest = vec![
375 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14,
376 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, 0x24,
377 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c,
378 0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, 0xb8, 0x55,
379 ];
380
381 let result = digest_to_blob_cid_string(&digest).unwrap();
382
383 assert!(result.starts_with('b'), "Should use base32 encoding");
384 assert!(result.len() > 40, "Base32-encoded CID should be reasonably long");
385
386 let parsed = cid::Cid::try_from(result.as_str()).unwrap();
387 assert_eq!(parsed.version(), cid::Version::V1);
388 assert_eq!(parsed.codec(), 0x55); // raw codec for blobs
389 }
390
391 #[test]
392 fn test_record_cid_string_real_example() {
393 let digest = vec![
394 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14,
395 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, 0x24,
396 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c,
397 0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, 0xb8, 0x55,
398 ];
399
400 let result = digest_to_record_cid_string(&digest).unwrap();
401
402 assert!(result.starts_with('b'), "Should use base32 encoding");
403 assert!(result.len() > 40, "Base32-encoded CID should be reasonably long");
404
405 let parsed = cid::Cid::try_from(result.as_str()).unwrap();
406 assert_eq!(parsed.version(), cid::Version::V1);
407 assert_eq!(parsed.codec(), 0x71); // dag-cbor codec for records
408 }
409
410 #[test]
411 fn test_roundtrip_blob_with_string() {
412 let original_digest = vec![0x42; 32];
413
414 let cid_string = digest_to_blob_cid_string(&original_digest).unwrap();
415 let parsed_cid = cid::Cid::try_from(cid_string.as_str()).unwrap();
416 let cid_bytes = parsed_cid.to_bytes();
417 let recovered_digest = cid_to_digest(&cid_bytes).unwrap();
418
419 assert_eq!(recovered_digest, &original_digest[..]);
420 assert!(cid_string.starts_with("bafkrei"));
421 }
422
423 #[test]
424 fn test_roundtrip_record_with_string() {
425 let original_digest = vec![0x99; 32];
426
427 let cid_string = digest_to_record_cid_string(&original_digest).unwrap();
428 let parsed_cid = cid::Cid::try_from(cid_string.as_str()).unwrap();
429 let cid_bytes = parsed_cid.to_bytes();
430 let recovered_digest = cid_to_digest(&cid_bytes).unwrap();
431
432 assert_eq!(recovered_digest, &original_digest[..]);
433 assert!(cid_string.starts_with("bafyrei"));
434 }
435
436 #[test]
437 fn test_synthetic_cid_determinism() {
438 // Synthetic CIDs must be deterministic
439 assert_eq!(
440 like_cid_string(123, 456),
441 like_cid_string(123, 456),
442 "Like CIDs must be deterministic"
443 );
444 assert_eq!(
445 repost_cid_string(123, 456),
446 repost_cid_string(123, 456),
447 "Repost CIDs must be deterministic"
448 );
449 assert_eq!(
450 follow_cid_string(123, 456),
451 follow_cid_string(123, 456),
452 "Follow CIDs must be deterministic"
453 );
454 assert_eq!(
455 block_cid_string(123, 456),
456 block_cid_string(123, 456),
457 "Block CIDs must be deterministic"
458 );
459 assert_eq!(
460 post_cid_string(123, 456),
461 post_cid_string(123, 456),
462 "Post CIDs must be deterministic"
463 );
464 }
465
466 #[test]
467 fn test_synthetic_cid_uniqueness() {
468 // Different records must produce different CIDs
469 let like_cid = like_cid_string(123, 456);
470 let repost_cid = repost_cid_string(123, 456);
471 let follow_cid = follow_cid_string(123, 456);
472 let block_cid = block_cid_string(123, 456);
473 let post_cid = post_cid_string(123, 456);
474
475 assert_ne!(like_cid, repost_cid, "Like and repost CIDs must differ");
476 assert_ne!(like_cid, follow_cid, "Like and follow CIDs must differ");
477 assert_ne!(like_cid, block_cid, "Like and block CIDs must differ");
478 assert_ne!(like_cid, post_cid, "Like and post CIDs must differ");
479 assert_ne!(repost_cid, follow_cid, "Repost and follow CIDs must differ");
480 assert_ne!(repost_cid, block_cid, "Repost and block CIDs must differ");
481 assert_ne!(repost_cid, post_cid, "Repost and post CIDs must differ");
482
483 // Different actors/rkeys must produce different CIDs
484 assert_ne!(like_cid_string(123, 456), like_cid_string(124, 456));
485 assert_ne!(like_cid_string(123, 456), like_cid_string(123, 457));
486 assert_ne!(repost_cid_string(123, 456), repost_cid_string(124, 456));
487 assert_ne!(post_cid_string(123, 456), post_cid_string(123, 457));
488 }
489
490 #[test]
491 fn test_synthetic_cid_format() {
492 // All synthetic CIDs must be valid AT Protocol record CIDs
493 assert!(like_cid_string(1, 1).starts_with("bafyrei"), "Must use record CID format");
494 assert!(repost_cid_string(1, 1).starts_with("bafyrei"));
495 assert!(follow_cid_string(1, 1).starts_with("bafyrei"));
496 assert!(block_cid_string(1, 1).starts_with("bafyrei"));
497 assert!(post_cid_string(1, 1).starts_with("bafyrei"));
498
499 // Verify they're parseable as valid CIDs
500 let like = like_cid_string(42, 9999);
501 let parsed = cid::Cid::try_from(like.as_str()).unwrap();
502 assert_eq!(parsed.version(), cid::Version::V1);
503 assert_eq!(parsed.codec(), 0x71); // dag-cbor codec for records
504 }
505}
506
507// ============================================================================
508// Jacquard Type Conversions
509// ============================================================================
510//
511// This section provides conversion utilities between jacquard's borrowed types
512// and parakeet's storage format. It preserves the existing CID optimization
513// where we store only 32-byte digests instead of full 36-byte CIDs.
514
515use jacquard_common::types::blob::Blob;
516use jacquard_common::types::cid::Cid as JacquardCid;
517use jacquard_common::IntoStatic;
518
519/// Storage format for Blob data
520#[derive(Debug, Clone)]
521pub struct BlobStorage {
522 pub mime_type: String,
523 pub cid: Vec<u8>, // 32-byte digest
524 pub size: i32,
525}
526
527/// Storage format for StrongRef data
528#[derive(Debug, Clone)]
529pub struct StrongRefStorage {
530 pub uri: String,
531 pub cid: Vec<u8>, // 32-byte digest
532}
533
534/// Convert jacquard Blob to storage format
535///
536/// Extracts the 32-byte digest from the CID for database storage
537pub fn blob_to_storage(blob: &Blob) -> Option<BlobStorage> {
538 // Convert jacquard CID to cid::Cid to get bytes
539 let cid = blob.cid().as_str().parse::<cid::Cid>().ok()?;
540 let cid_bytes = cid.to_bytes();
541 let digest = cid_to_digest_owned(&cid_bytes)?;
542
543 Some(BlobStorage {
544 mime_type: blob.mime_type.to_string(),
545 cid: digest,
546 size: blob.size as i32,
547 })
548}
549
550/// Convert jacquard StrongRef to storage format
551///
552/// Extracts the 32-byte digest from the CID for database storage
553pub fn strongref_to_storage(sr: &jacquard_api::com_atproto::repo::strong_ref::StrongRef) -> Option<StrongRefStorage> {
554 // Convert jacquard CID string to cid::Cid to get bytes
555 let cid = sr.cid.parse::<cid::Cid>().ok()?;
556 let cid_bytes = cid.to_bytes();
557 let digest = cid_to_digest_owned(&cid_bytes)?;
558
559 Some(StrongRefStorage {
560 uri: sr.uri.to_string(),
561 cid: digest,
562 })
563}
564
565/// Convert jacquard CID to 32-byte digest for storage
566pub fn jacquard_cid_to_digest(cid: &JacquardCid) -> Option<Vec<u8>> {
567 // Convert jacquard CID to cid::Cid first
568 let parsed_cid = cid.as_str().parse::<cid::Cid>().ok()?;
569 let cid_bytes = parsed_cid.to_bytes();
570 cid_to_digest_owned(&cid_bytes)
571}
572
573/// Reconstruct a jacquard CID from a 32-byte digest
574///
575/// # Arguments
576/// * `digest` - The 32-byte SHA-256 digest
577/// * `is_blob` - If true, creates a blob CID (raw codec), otherwise record CID (dag-cbor)
578pub fn digest_to_jacquard_cid(digest: &[u8], is_blob: bool) -> Option<JacquardCid<'static>> {
579 if digest.len() != 32 {
580 return None;
581 }
582
583 let mut cid_bytes = Vec::with_capacity(36);
584 if is_blob {
585 cid_bytes.extend_from_slice(&BLOB_CID_HEADER);
586 } else {
587 cid_bytes.extend_from_slice(&RECORD_CID_HEADER);
588 }
589 cid_bytes.extend_from_slice(digest);
590
591 // Convert to cid::Cid first, then to string for jacquard
592 let cid = cid::Cid::try_from(cid_bytes).ok()?;
593 let cid_str = cid.to_string();
594 // Use the proper constructor for JacquardCid
595 Some(JacquardCid::from(cid_str))
596}
597
598/// Extract parts from a jacquard StrongRef
599///
600/// Returns (uri, cid_string) tuple
601pub fn strongref_to_parts(sr: Option<&jacquard_api::com_atproto::repo::strong_ref::StrongRef>) -> (Option<String>, Option<String>) {
602 match sr {
603 Some(sr) => (
604 Some(sr.uri.to_string()),
605 Some(sr.cid.to_string()),
606 ),
607 None => (None, None),
608 }
609}
610
611/// Extract parts from a jacquard StrongRef with digest
612///
613/// Returns (uri, cid_digest_bytes) tuple
614pub fn strongref_to_parts_with_digest(sr: Option<&jacquard_api::com_atproto::repo::strong_ref::StrongRef>) -> (Option<String>, Option<Vec<u8>>) {
615 match sr {
616 Some(sr) => {
617 // Parse CID and extract digest
618 let cid = sr.cid.parse::<cid::Cid>().ok();
619 let digest = cid.and_then(|c| {
620 let bytes = c.to_bytes();
621 cid_to_digest_owned(&bytes)
622 });
623 (Some(sr.uri.to_string()), digest)
624 },
625 None => (None, None),
626 }
627}
628
629/// Extract CID string from a jacquard Blob
630pub fn blob_ref(blob: Option<&Blob>) -> Option<String> {
631 blob.map(|b| b.cid().to_string())
632}
633
634/// Extract CID digest bytes from a jacquard Blob
635pub fn blob_to_cid_bytes(blob: Option<&Blob>) -> Option<Vec<u8>> {
636 blob.and_then(|b| {
637 // Convert jacquard CID to cid::Cid to get bytes
638 let cid = b.cid().as_str().parse::<cid::Cid>().ok()?;
639 let cid_bytes = cid.to_bytes();
640 cid_to_digest_owned(&cid_bytes)
641 })
642}
643
644/// Convert storage format back to jacquard Blob
645///
646/// Reconstructs the full CID from the stored digest
647pub fn storage_to_blob(storage: &BlobStorage) -> Option<Blob<'static>> {
648 // Reconstruct the full CID from digest
649 let cid = digest_to_jacquard_cid(&storage.cid, true)?;
650
651 // Create a static blob using JSON deserialization
652 // We need to create a JSON string and deserialize from it to get the proper lifetime
653 let blob_json_str = serde_json::json!({
654 "$type": "blob",
655 "ref": {"$link": cid.as_str()},
656 "mimeType": &storage.mime_type,
657 "size": storage.size
658 }).to_string();
659
660 // Deserialize from string to get 'static lifetime
661 serde_json::from_str(&blob_json_str).ok().map(|b: Blob<'_>| b.into_static())
662}
663
664/// Convert storage format back to jacquard StrongRef
665///
666/// Reconstructs the full CID from the stored digest
667pub fn storage_to_strongref(storage: &StrongRefStorage) -> Option<jacquard_api::com_atproto::repo::strong_ref::StrongRef<'static>> {
668 // Reconstruct the full CID from digest (record CID, not blob)
669 let cid = digest_to_jacquard_cid(&storage.cid, false)?;
670
671 // Parse AtUri from string
672 let uri: jacquard_common::types::string::AtUri<'static> = storage.uri.parse().ok()?;
673
674 // Use builder to create StrongRef
675 use jacquard_api::com_atproto::repo::strong_ref::StrongRef;
676
677 Some(StrongRef::new()
678 .uri(uri)
679 .cid(cid)
680 .build())
681}
682
683#[cfg(test)]
684mod jacquard_tests {
685 use super::*;
686
687 #[test]
688 fn test_jacquard_cid_round_trip() {
689 let digest = vec![0u8; 32]; // Example digest
690
691 // Test blob CID
692 let blob_cid = digest_to_jacquard_cid(&digest, true).unwrap();
693 assert!(blob_cid.as_str().starts_with("bafkrei")); // Blob CIDs start with this
694
695 // Test record CID
696 let record_cid = digest_to_jacquard_cid(&digest, false).unwrap();
697 assert!(record_cid.as_str().starts_with("bafyrei")); // Record CIDs start with this
698 }
699}