1use crate::cx::Cx;
4use crate::net::atp::protocol::{AtpError, AtpOutcome, DiskError, ManifestError, PlatformError};
5
6macro_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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct AtpObject {
26 pub hash: ObjectHash,
28 pub size_bytes: u64,
30 pub content_type: String,
32 pub metadata: ObjectMetadata,
34 pub created_at_nanos: u64,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40pub struct ObjectHash(pub [u8; 32]);
41
42impl ObjectHash {
43 #[must_use]
45 pub const fn new(bytes: [u8; 32]) -> Self {
46 Self(bytes)
47 }
48
49 #[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 #[must_use]
59 pub const fn as_bytes(&self) -> &[u8; 32] {
60 &self.0
61 }
62
63 #[must_use]
65 pub fn hex(&self) -> String {
66 hex::encode(self.0)
67 }
68
69 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ObjectMetadata {
91 pub fields: HashMap<String, String>,
93}
94
95impl ObjectMetadata {
96 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 pub fn insert(&mut self, key: String, value: String) {
104 self.fields.insert(key, value);
105 }
106
107 #[must_use]
109 pub fn get(&self, key: &str) -> Option<&str> {
110 self.fields.get(key).map(String::as_str)
111 }
112
113 pub fn remove(&mut self, key: &str) -> Option<String> {
115 self.fields.remove(key)
116 }
117
118 #[must_use]
120 pub fn contains_key(&self, key: &str) -> bool {
121 self.fields.contains_key(key)
122 }
123
124 pub fn keys(&self) -> impl Iterator<Item = &str> {
126 self.fields.keys().map(String::as_str)
127 }
128
129 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct ObjectManifest {
154 pub version: u32,
156 pub root_hash: ObjectHash,
158 pub objects: Vec<ManifestEntry>,
160 pub metadata: ObjectMetadata,
162 pub created_at_nanos: u64,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct ManifestEntry {
169 pub hash: ObjectHash,
171 pub path: String,
173 pub size_bytes: u64,
175 pub content_type: String,
177 pub metadata: ObjectMetadata,
179}
180
181#[allow(async_fn_in_trait)]
183pub trait ObjectStore {
184 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 async fn get_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<Vec<u8>>>;
195
196 async fn get_object_info(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<Option<AtpObject>>;
198
199 async fn has_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool>;
201
202 async fn delete_object(&self, cx: &Cx, hash: &ObjectHash) -> AtpOutcome<bool>;
204
205 async fn list_objects(&self, cx: &Cx) -> AtpOutcome<Vec<ObjectHash>>;
207}
208
209type 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 #[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(), 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#[derive(Debug, Clone)]
339pub struct FileSystemObjectStore {
340 base_path: PathBuf,
341}
342
343impl FileSystemObjectStore {
344 #[must_use]
346 pub fn new(base_path: PathBuf) -> Self {
347 Self { base_path }
348 }
349
350 fn object_path(&self, hash: &ObjectHash) -> PathBuf {
352 let hex = hash.hex();
353 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 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(), 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 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 try_atp!(crate::fs::write(&object_path, &data).await, |_| {
412 AtpError::Disk(DiskError::IoError)
413 });
414
415 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 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 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#[derive(Debug)]
578pub struct ManifestBuilder {
579 entries: Vec<ManifestEntry>,
580 metadata: ObjectMetadata,
581}
582
583impl ManifestBuilder {
584 #[must_use]
586 pub fn new() -> Self {
587 Self {
588 entries: Vec::new(),
589 metadata: ObjectMetadata::new(),
590 }
591 }
592
593 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 pub fn add_metadata(&mut self, key: String, value: String) {
613 self.metadata.insert(key, value);
614 }
615
616 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); 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 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 let exists = store.has_object(&cx, &object.hash).await.unwrap();
693 assert!(exists);
694
695 let retrieved = store.get_object(&cx, &object.hash).await.unwrap();
697 assert_eq!(retrieved, Some(data));
698
699 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 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 let object = store
765 .store_object(&cx, data.clone(), content_type, metadata)
766 .await
767 .unwrap();
768
769 let object_path = store.object_path(&object.hash);
771 assert!(object_path.exists());
772
773 let retrieved = store.get_object(&cx, &object.hash).await.unwrap();
775 assert_eq!(retrieved, Some(data));
776
777 let info = store.get_object_info(&cx, &object.hash).await.unwrap();
779 assert!(info.is_some());
780
781 let objects = store.list_objects(&cx).await.unwrap();
783 assert!(objects.contains(&object.hash));
784
785 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}