Skip to main content

asupersync/net/atp/sdk/
object.rs

1//! ATP object management and content-addressed storage.
2
3use crate::cx::Cx;
4use crate::net::atp::protocol::{AtpError, AtpOutcome, DiskError, ManifestError, PlatformError};
5
6/// Helper macro to handle Result<T, E> in functions returning AtpOutcome<U>.
7/// Converts Result errors using the provided mapper and returns early on error.
8macro_rules! try_atp {
9    ($expr:expr, $error_mapper:expr) => {
10        match $expr {
11            Ok(v) => v,
12            Err(e) => return AtpOutcome::Err($error_mapper(e)),
13        }
14    };
15}
16use crate::sync::{LockError, Mutex, MutexGuard};
17use crate::types::CancelReason;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23/// Content-addressed object with hash-based identity.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct AtpObject {
26    /// Content hash (SHA-256).
27    pub hash: ObjectHash,
28    /// Object size in bytes.
29    pub size_bytes: u64,
30    /// Content type/MIME type.
31    pub content_type: String,
32    /// Object metadata.
33    pub metadata: ObjectMetadata,
34    /// Object creation timestamp.
35    pub created_at_nanos: u64,
36}
37
38/// Object hash type.
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40pub struct ObjectHash(pub [u8; 32]);
41
42impl ObjectHash {
43    /// Create from hash bytes.
44    #[must_use]
45    pub const fn new(bytes: [u8; 32]) -> Self {
46        Self(bytes)
47    }
48
49    /// Compute hash from data.
50    #[must_use]
51    pub fn from_data(data: &[u8]) -> Self {
52        let mut hasher = Sha256::new();
53        hasher.update(data);
54        Self(hasher.finalize().into())
55    }
56
57    /// Get hash bytes.
58    #[must_use]
59    pub const fn as_bytes(&self) -> &[u8; 32] {
60        &self.0
61    }
62
63    /// Get hex representation.
64    #[must_use]
65    pub fn hex(&self) -> String {
66        hex::encode(self.0)
67    }
68
69    /// Create from hex string.
70    pub fn from_hex(hex_str: &str) -> Result<Self, hex::FromHexError> {
71        let bytes = hex::decode(hex_str)?;
72        if bytes.len() == 32 {
73            let mut array = [0u8; 32];
74            array.copy_from_slice(&bytes);
75            Ok(Self(array))
76        } else {
77            Err(hex::FromHexError::InvalidStringLength)
78        }
79    }
80}
81
82impl std::fmt::Display for ObjectHash {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{}", self.hex())
85    }
86}
87
88/// Object metadata key-value pairs.
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ObjectMetadata {
91    /// Custom metadata fields.
92    pub fields: HashMap<String, String>,
93}
94
95impl ObjectMetadata {
96    /// Create new empty metadata.
97    #[must_use]
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Add a metadata field.
103    pub fn insert(&mut self, key: String, value: String) {
104        self.fields.insert(key, value);
105    }
106
107    /// Get a metadata field.
108    #[must_use]
109    pub fn get(&self, key: &str) -> Option<&str> {
110        self.fields.get(key).map(String::as_str)
111    }
112
113    /// Remove a metadata field.
114    pub fn remove(&mut self, key: &str) -> Option<String> {
115        self.fields.remove(key)
116    }
117
118    /// Check if metadata contains a key.
119    #[must_use]
120    pub fn contains_key(&self, key: &str) -> bool {
121        self.fields.contains_key(key)
122    }
123
124    /// Get all field names.
125    pub fn keys(&self) -> impl Iterator<Item = &str> {
126        self.fields.keys().map(String::as_str)
127    }
128
129    /// Create metadata with common fields.
130    #[must_use]
131    pub fn with_filename(filename: &str) -> Self {
132        let mut metadata = Self::new();
133        metadata.insert("filename".to_string(), filename.to_string());
134        metadata
135    }
136
137    /// Create metadata with source path.
138    #[must_use]
139    pub fn with_source_path(path: &Path) -> Self {
140        let mut metadata = Self::new();
141        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
142            metadata.insert("filename".to_string(), filename.to_string());
143        }
144        if let Some(parent) = path.parent().and_then(|p| p.to_str()) {
145            metadata.insert("source_directory".to_string(), parent.to_string());
146        }
147        metadata
148    }
149}
150
151/// Object manifest for hierarchical object graphs.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct ObjectManifest {
154    /// Manifest version.
155    pub version: u32,
156    /// Root object hash.
157    pub root_hash: ObjectHash,
158    /// Object entries in the manifest.
159    pub objects: Vec<ManifestEntry>,
160    /// Manifest metadata.
161    pub metadata: ObjectMetadata,
162    /// Manifest creation timestamp.
163    pub created_at_nanos: u64,
164}
165
166/// Entry in an object manifest.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ManifestEntry {
169    /// Object hash.
170    pub hash: ObjectHash,
171    /// Relative path in the object graph.
172    pub path: String,
173    /// Object size in bytes.
174    pub size_bytes: u64,
175    /// Content type.
176    pub content_type: String,
177    /// Entry-specific metadata.
178    pub metadata: ObjectMetadata,
179}
180
181/// Object store for managing ATP objects.
182#[allow(async_fn_in_trait)]
183pub trait ObjectStore {
184    /// Store an object and return its hash.
185    async fn store_object(
186        &self,
187        cx: &Cx,
188        data: Vec<u8>,
189        content_type: &str,
190        metadata: ObjectMetadata,
191    ) -> AtpOutcome<AtpObject>;
192
193    /// Retrieve an object by hash.
194    async fn get_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<Vec<u8>>>;
195
196    /// Get object metadata without data.
197    async fn get_object_info(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<AtpObject>>;
198
199    /// Check if an object exists.
200    async fn has_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool>;
201
202    /// Delete an object.
203    async fn delete_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool>;
204
205    /// List all objects (for debugging/admin).
206    async fn list_objects(&self, cx: &Cx) -> AtpOutcome<Vec<ObjectHash>>;
207}
208
209/// In-memory object store implementation.
210type MemoryObjectMap = HashMap<ObjectHash, (Vec<u8>, AtpObject)>;
211
212#[derive(Debug)]
213pub struct MemoryObjectStore {
214    objects: Mutex<MemoryObjectMap>,
215}
216
217impl Default for MemoryObjectStore {
218    fn default() -> Self {
219        Self {
220            objects: Mutex::with_name("atp_memory_object_store", MemoryObjectMap::new()),
221        }
222    }
223}
224
225impl MemoryObjectStore {
226    /// Create a new in-memory object store.
227    #[must_use]
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    fn current_time_nanos() -> u64 {
233        use std::time::{SystemTime, UNIX_EPOCH};
234        let nanos = SystemTime::now()
235            .duration_since(UNIX_EPOCH)
236            .unwrap_or_default()
237            .as_nanos();
238        u64::try_from(nanos).unwrap_or(u64::MAX)
239    }
240
241    async fn lock_objects(&self, cx: &Cx) -> AtpOutcome<MutexGuard<'_, MemoryObjectMap>> {
242        match self.objects.lock(cx).await {
243            Ok(objects) => AtpOutcome::ok(objects),
244            Err(LockError::Cancelled) => AtpOutcome::cancelled(
245                cx.cancel_reason()
246                    .unwrap_or_else(CancelReason::parent_cancelled),
247            ),
248            Err(LockError::TimedOut(_)) => AtpOutcome::cancelled(CancelReason::timeout()),
249            Err(LockError::Poisoned | LockError::PolledAfterCompletion) => {
250                AtpOutcome::Err(AtpError::Platform(PlatformError::OperatingSystemError))
251            }
252        }
253    }
254}
255
256impl ObjectStore for MemoryObjectStore {
257    async fn store_object(
258        &self,
259        cx: &Cx,
260        data: Vec<u8>,
261        content_type: &str,
262        metadata: ObjectMetadata,
263    ) -> AtpOutcome<AtpObject> {
264        let hash = ObjectHash::from_data(&data);
265        let size_bytes = data.len() as u64;
266
267        let object = AtpObject {
268            hash: hash.clone(),
269            size_bytes,
270            content_type: content_type.to_string(), // ubs:ignore - struct field initialization
271            metadata,
272            created_at_nanos: Self::current_time_nanos(),
273        };
274
275        let mut objects = match self.lock_objects(cx).await {
276            AtpOutcome::Ok(objects) => objects,
277            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
278            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
279            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
280        };
281        objects.insert(hash, (data, object.clone()));
282
283        AtpOutcome::ok(object)
284    }
285
286    async fn get_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<Vec<u8>>> {
287        let objects = match self.lock_objects(cx).await {
288            AtpOutcome::Ok(objects) => objects,
289            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
290            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
291            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
292        };
293        AtpOutcome::ok(objects.get(hash).map(|(data, _)| data.clone()))
294    }
295
296    async fn get_object_info(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<AtpObject>> {
297        let objects = match self.lock_objects(cx).await {
298            AtpOutcome::Ok(objects) => objects,
299            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
300            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
301            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
302        };
303        AtpOutcome::ok(objects.get(hash).map(|(_, object)| object.clone()))
304    }
305
306    async fn has_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool> {
307        let objects = match self.lock_objects(cx).await {
308            AtpOutcome::Ok(objects) => objects,
309            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
310            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
311            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
312        };
313        AtpOutcome::ok(objects.contains_key(hash))
314    }
315
316    async fn delete_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool> {
317        let mut objects = match self.lock_objects(cx).await {
318            AtpOutcome::Ok(objects) => objects,
319            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
320            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
321            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
322        };
323        AtpOutcome::ok(objects.remove(hash).is_some())
324    }
325
326    async fn list_objects(&self, cx: &Cx) -> AtpOutcome<Vec<ObjectHash>> {
327        let objects = match self.lock_objects(cx).await {
328            AtpOutcome::Ok(objects) => objects,
329            AtpOutcome::Err(error) => return AtpOutcome::Err(error),
330            AtpOutcome::Cancelled(reason) => return AtpOutcome::Cancelled(reason),
331            AtpOutcome::Panicked(payload) => return AtpOutcome::Panicked(payload),
332        };
333        AtpOutcome::ok(objects.keys().cloned().collect())
334    }
335}
336
337/// File system object store implementation.
338#[derive(Debug, Clone)]
339pub struct FileSystemObjectStore {
340    base_path: PathBuf,
341}
342
343impl FileSystemObjectStore {
344    /// Create a new file system object store.
345    #[must_use]
346    pub fn new(base_path: PathBuf) -> Self {
347        Self { base_path }
348    }
349
350    /// Get the path for an object hash.
351    fn object_path(&self, hash: &ObjectHash) -> PathBuf {
352        let hex = hash.hex();
353        // Use two-level directory structure: aa/bb/aabb...
354        let dir1 = &hex[0..2];
355        let dir2 = &hex[2..4];
356        let filename = &hex[4..];
357        self.base_path.join(dir1).join(dir2).join(filename)
358    }
359
360    /// Get the metadata path for an object hash.
361    fn metadata_path(&self, hash: &ObjectHash) -> PathBuf {
362        let mut path = self.object_path(hash);
363        path.set_extension("meta");
364        path
365    }
366
367    fn is_hash_path_component(name: &str, expected_len: usize) -> bool {
368        name.len() == expected_len && name.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
369    }
370
371    fn current_time_nanos() -> u64 {
372        use std::time::{SystemTime, UNIX_EPOCH};
373        let nanos = SystemTime::now()
374            .duration_since(UNIX_EPOCH)
375            .unwrap_or_default()
376            .as_nanos();
377        u64::try_from(nanos).unwrap_or(u64::MAX)
378    }
379}
380
381impl ObjectStore for FileSystemObjectStore {
382    async fn store_object(
383        &self,
384        _cx: &Cx,
385        data: Vec<u8>,
386        content_type: &str,
387        metadata: ObjectMetadata,
388    ) -> AtpOutcome<AtpObject> {
389        let hash = ObjectHash::from_data(&data);
390        let size_bytes = data.len() as u64;
391
392        let object = AtpObject {
393            hash: hash.clone(),
394            size_bytes,
395            content_type: content_type.to_string(), // ubs:ignore - struct field initialization
396            metadata,
397            created_at_nanos: Self::current_time_nanos(),
398        };
399
400        let object_path = self.object_path(&hash);
401        let metadata_path = self.metadata_path(&hash);
402
403        // Create parent directories
404        if let Some(parent) = object_path.parent() {
405            try_atp!(crate::fs::create_dir_all(parent).await, |_| AtpError::Disk(
406                DiskError::IoError
407            ));
408        }
409
410        // Write object data
411        try_atp!(crate::fs::write(&object_path, &data).await, |_| {
412            AtpError::Disk(DiskError::IoError)
413        });
414
415        // Write object metadata
416        let metadata_json = try_atp!(serde_json::to_vec_pretty(&object), |_| AtpError::Manifest(
417            ManifestError::InvalidFormat
418        ));
419        try_atp!(
420            crate::fs::write(&metadata_path, metadata_json).await,
421            |_| AtpError::Disk(DiskError::IoError)
422        );
423
424        AtpOutcome::ok(object)
425    }
426
427    async fn get_object(&self, _cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<Vec<u8>>> {
428        let object_path = self.object_path(hash);
429
430        if !object_path.exists() {
431            return AtpOutcome::ok(None);
432        }
433
434        let data = try_atp!(crate::fs::read(&object_path).await, |_| AtpError::Disk(
435            DiskError::IoError
436        ));
437
438        // Verify hash matches
439        let computed_hash = ObjectHash::from_data(&data);
440        if computed_hash != *hash {
441            return AtpOutcome::Err(AtpError::Manifest(ManifestError::HashMismatch));
442        }
443
444        AtpOutcome::ok(Some(data))
445    }
446
447    async fn get_object_info(&self, _cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<AtpObject>> {
448        let metadata_path = self.metadata_path(hash);
449
450        if !metadata_path.exists() {
451            return AtpOutcome::ok(None);
452        }
453
454        let metadata_json = try_atp!(crate::fs::read(&metadata_path).await, |_| AtpError::Disk(
455            DiskError::IoError
456        ));
457
458        let object: AtpObject = try_atp!(serde_json::from_slice(&metadata_json), |_| {
459            AtpError::Manifest(ManifestError::InvalidFormat)
460        });
461
462        AtpOutcome::ok(Some(object))
463    }
464
465    async fn has_object(&self, _cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool> {
466        let object_path = self.object_path(hash);
467        AtpOutcome::ok(object_path.exists())
468    }
469
470    async fn delete_object(&self, _cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool> {
471        let object_path = self.object_path(hash);
472        let metadata_path = self.metadata_path(hash);
473
474        if !object_path.exists() {
475            return AtpOutcome::ok(false);
476        }
477
478        // Remove both object data and metadata
479        let _ = crate::fs::remove_file(&object_path).await;
480        let _ = crate::fs::remove_file(&metadata_path).await;
481
482        AtpOutcome::ok(true)
483    }
484
485    async fn list_objects(&self, _cx: &Cx) -> AtpOutcome<Vec<ObjectHash>> {
486        let mut hashes = Vec::new();
487
488        let mut first_level = match crate::fs::read_dir(&self.base_path).await {
489            Ok(read_dir) => read_dir,
490            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
491                return AtpOutcome::ok(hashes);
492            }
493            Err(_) => return AtpOutcome::Err(AtpError::Disk(DiskError::IoError)),
494        };
495
496        while let Some(dir1_entry) = try_atp!(first_level.next_entry().await, |_| {
497            AtpError::Disk(DiskError::IoError)
498        }) {
499            let Some(dir1) = dir1_entry.file_name().to_str().map(str::to_owned) else {
500                continue;
501            };
502            if !Self::is_hash_path_component(&dir1, 2) {
503                continue;
504            }
505
506            let file_type = try_atp!(dir1_entry.file_type().await, |_| AtpError::Disk(
507                DiskError::IoError
508            ));
509            if !file_type.is_dir() {
510                continue;
511            }
512
513            let mut second_level = try_atp!(crate::fs::read_dir(dir1_entry.path()).await, |_| {
514                AtpError::Disk(DiskError::IoError)
515            });
516
517            while let Some(dir2_entry) = try_atp!(second_level.next_entry().await, |_| {
518                AtpError::Disk(DiskError::IoError)
519            }) {
520                let Some(dir2) = dir2_entry.file_name().to_str().map(str::to_owned) else {
521                    continue;
522                };
523                if !Self::is_hash_path_component(&dir2, 2) {
524                    continue;
525                }
526
527                let file_type = try_atp!(dir2_entry.file_type().await, |_| AtpError::Disk(
528                    DiskError::IoError
529                ));
530                if !file_type.is_dir() {
531                    continue;
532                }
533
534                let mut object_entries =
535                    try_atp!(crate::fs::read_dir(dir2_entry.path()).await, |_| {
536                        AtpError::Disk(DiskError::IoError)
537                    });
538
539                while let Some(object_entry) = try_atp!(object_entries.next_entry().await, |_| {
540                    AtpError::Disk(DiskError::IoError)
541                }) {
542                    let Some(object_name) = object_entry.file_name().to_str().map(str::to_owned)
543                    else {
544                        continue;
545                    };
546                    if std::path::Path::new(&object_name)
547                        .extension()
548                        .is_some_and(|ext| ext.eq_ignore_ascii_case("meta"))
549                        || !Self::is_hash_path_component(&object_name, 60)
550                    {
551                        continue;
552                    }
553
554                    let file_type = try_atp!(object_entry.file_type().await, |_| {
555                        AtpError::Disk(DiskError::IoError)
556                    });
557                    if !file_type.is_file() {
558                        continue;
559                    }
560
561                    let hash_hex = format!("{dir1}{dir2}{object_name}");
562                    if let Ok(hash) = ObjectHash::from_hex(&hash_hex) {
563                        hashes.push(hash);
564                    }
565                }
566            }
567        }
568
569        hashes.sort();
570        hashes.dedup();
571
572        AtpOutcome::ok(hashes)
573    }
574}
575
576/// Object manifest builder for creating hierarchical object graphs.
577#[derive(Debug)]
578pub struct ManifestBuilder {
579    entries: Vec<ManifestEntry>,
580    metadata: ObjectMetadata,
581}
582
583impl ManifestBuilder {
584    /// Create a new manifest builder.
585    #[must_use]
586    pub fn new() -> Self {
587        Self {
588            entries: Vec::new(),
589            metadata: ObjectMetadata::new(),
590        }
591    }
592
593    /// Add an object to the manifest.
594    pub fn add_object(
595        &mut self,
596        hash: ObjectHash,
597        path: String,
598        size_bytes: u64,
599        content_type: String,
600        metadata: ObjectMetadata,
601    ) {
602        self.entries.push(ManifestEntry {
603            hash,
604            path,
605            size_bytes,
606            content_type,
607            metadata,
608        });
609    }
610
611    /// Add metadata to the manifest.
612    pub fn add_metadata(&mut self, key: String, value: String) {
613        self.metadata.insert(key, value);
614    }
615
616    /// Build the final manifest.
617    pub fn build(self, root_hash: ObjectHash) -> ObjectManifest {
618        ObjectManifest {
619            version: 1,
620            root_hash,
621            objects: self.entries,
622            metadata: self.metadata,
623            created_at_nanos: FileSystemObjectStore::current_time_nanos(),
624        }
625    }
626}
627
628impl Default for ManifestBuilder {
629    fn default() -> Self {
630        Self::new()
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use futures_lite::future::block_on;
638
639    #[test]
640    fn object_hash_creation() {
641        let data = b"hello world";
642        let hash = ObjectHash::from_data(data);
643
644        let hex = hash.hex();
645        assert_eq!(hex.len(), 64); // SHA-256 is 32 bytes = 64 hex chars
646
647        let parsed_hash = ObjectHash::from_hex(&hex).unwrap();
648        assert_eq!(hash, parsed_hash);
649    }
650
651    #[test]
652    fn object_metadata_operations() {
653        let mut metadata = ObjectMetadata::new();
654        assert!(metadata.fields.is_empty());
655
656        metadata.insert("key1".to_string(), "value1".to_string());
657        metadata.insert("key2".to_string(), "value2".to_string());
658
659        assert_eq!(metadata.get("key1"), Some("value1"));
660        assert_eq!(metadata.get("key2"), Some("value2"));
661        assert_eq!(metadata.get("key3"), None);
662
663        assert!(metadata.contains_key("key1"));
664        assert!(!metadata.contains_key("key3"));
665
666        let removed = metadata.remove("key1");
667        assert_eq!(removed, Some("value1".to_string()));
668        assert_eq!(metadata.get("key1"), None);
669    }
670
671    #[test]
672    fn memory_object_store() {
673        crate::test_utils::init_test_logging();
674
675        let cx = crate::cx::Cx::for_testing();
676
677        let store = MemoryObjectStore::new();
678        let data = b"test data".to_vec();
679        let content_type = "text/plain";
680        let metadata = ObjectMetadata::with_filename("test.txt");
681
682        block_on(async {
683            // Store object
684            let object = store
685                .store_object(&cx, data.clone(), content_type, metadata)
686                .await
687                .unwrap();
688            assert_eq!(object.size_bytes, data.len() as u64);
689            assert_eq!(object.content_type, content_type);
690
691            // Check existence
692            let exists = store.has_object(&cx, &object.hash).await.unwrap();
693            assert!(exists);
694
695            // Retrieve object
696            let retrieved = store.get_object(&cx, &object.hash).await.unwrap();
697            assert_eq!(retrieved, Some(data));
698
699            // Get object info
700            let info = store.get_object_info(&cx, &object.hash).await.unwrap();
701            assert!(info.is_some());
702            assert_eq!(info.unwrap().hash, object.hash);
703
704            // Delete object
705            let deleted = store.delete_object(&cx, &object.hash).await.unwrap();
706            assert!(deleted);
707
708            let exists_after_delete = store.has_object(&cx, &object.hash).await.unwrap();
709            assert!(!exists_after_delete);
710        });
711
712        crate::test_complete!("memory_object_store");
713    }
714
715    #[test]
716    fn manifest_builder() {
717        let mut builder = ManifestBuilder::new();
718
719        let hash1 = ObjectHash::from_data(b"data1");
720        let hash2 = ObjectHash::from_data(b"data2");
721
722        builder.add_object(
723            hash1.clone(),
724            "file1.txt".to_string(),
725            5,
726            "text/plain".to_string(),
727            ObjectMetadata::with_filename("file1.txt"),
728        );
729
730        builder.add_object(
731            hash2.clone(),
732            "file2.txt".to_string(),
733            5,
734            "text/plain".to_string(),
735            ObjectMetadata::with_filename("file2.txt"),
736        );
737
738        builder.add_metadata("description".to_string(), "test manifest".to_string());
739
740        let manifest = builder.build(hash1.clone());
741
742        assert_eq!(manifest.root_hash, hash1);
743        assert_eq!(manifest.objects.len(), 2);
744        assert_eq!(manifest.version, 1);
745        assert_eq!(manifest.metadata.get("description"), Some("test manifest"));
746    }
747
748    #[test]
749    fn filesystem_object_store() {
750        crate::test_utils::init_test_logging();
751
752        use tempfile::tempdir;
753
754        let cx = crate::cx::Cx::for_testing();
755
756        let temp_dir = tempdir().unwrap();
757        let store = FileSystemObjectStore::new(temp_dir.path().to_path_buf());
758        let data = b"filesystem test data".to_vec();
759        let content_type = "application/octet-stream";
760        let metadata = ObjectMetadata::with_filename("fstest.bin");
761
762        block_on(async {
763            // Store object
764            let object = store
765                .store_object(&cx, data.clone(), content_type, metadata)
766                .await
767                .unwrap();
768
769            // Verify file was created
770            let object_path = store.object_path(&object.hash);
771            assert!(object_path.exists());
772
773            // Retrieve object
774            let retrieved = store.get_object(&cx, &object.hash).await.unwrap();
775            assert_eq!(retrieved, Some(data));
776
777            // Get object info
778            let info = store.get_object_info(&cx, &object.hash).await.unwrap();
779            assert!(info.is_some());
780
781            // List objects
782            let objects = store.list_objects(&cx).await.unwrap();
783            assert!(objects.contains(&object.hash));
784
785            // Delete object
786            let deleted = store.delete_object(&cx, &object.hash).await.unwrap();
787            assert!(deleted);
788            assert!(!object_path.exists());
789        });
790
791        crate::test_complete!("filesystem_object_store");
792    }
793}