1use std::ops::Deref;
2use std::path::{Path, PathBuf};
3use tracing::debug;
4
5use crate::atomic_file::FileError;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum PathTokenError {
22 Empty,
24 Separator,
28 ParentDir,
32 CurDir,
37 NulByte,
39 Colon,
42 Unindexable,
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum RequiredLocalBlobPathError {
49 #[error("local blob path: {0}")]
50 Path(#[from] PathTokenError),
51 #[error("local blob {namespace}/{id} is absent")]
52 Missing { namespace: String, id: String },
53 #[error("local blob file: {0}")]
54 File(#[from] FileError),
55}
56
57#[derive(Debug, thiserror::Error)]
58pub enum CachedLocatorRemovalError {
59 #[error("blob cache path: {0}")]
60 Path(#[from] PathTokenError),
61 #[error("blob cache file: {0}")]
62 File(#[from] FileError),
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum LocalBlobRemovalError {
67 #[error("local blob path: {0}")]
68 Path(#[from] PathTokenError),
69 #[error("local blob file: {0}")]
70 File(#[from] FileError),
71}
72
73#[derive(Debug, thiserror::Error)]
74pub enum LocalBlobStoreError {
75 #[error("local blob path: {0}")]
76 Path(#[from] PathTokenError),
77 #[error("local blob file: {0}")]
78 File(#[from] FileError),
79 #[error("local blob {} has {actual_size} bytes, expected {expected_size}", path.display())]
80 SizeMismatch {
81 path: PathBuf,
82 expected_size: u64,
83 actual_size: u64,
84 },
85}
86
87#[derive(Debug, thiserror::Error)]
88pub enum StoreBlobFileError {
89 #[error("store blob path: {0}")]
90 Path(#[from] PathTokenError),
91 #[error("store blob file: {0}")]
92 File(#[from] FileError),
93 #[error("commit store blob: {0}")]
94 Commit(#[from] crate::local_file::CommitNewFileError),
95 #[error("store blob {} has size/hash {actual_size}/{actual_hash}, expected {expected_size}/{expected_hash}", path.display())]
96 Integrity {
97 path: PathBuf,
98 expected_size: u64,
99 actual_size: u64,
100 expected_hash: crate::object_hash::ObjectHash,
101 actual_hash: crate::object_hash::ObjectHash,
102 },
103}
104
105pub struct CachedBlobFile {
106 path: PathBuf,
107 recency: u64,
108 size: u64,
109}
110
111impl CachedBlobFile {
112 pub fn path(&self) -> &Path {
113 &self.path
114 }
115
116 pub fn recency(&self) -> u64 {
117 self.recency
118 }
119
120 pub fn size(&self) -> u64 {
121 self.size
122 }
123}
124
125impl std::fmt::Display for PathTokenError {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
128 PathTokenError::Empty => write!(f, "path token is empty"),
129 PathTokenError::Separator => write!(f, "path token contains a path separator"),
130 PathTokenError::ParentDir => write!(f, "path token contains a parent reference"),
131 PathTokenError::CurDir => write!(f, "path token is a current-directory reference"),
132 PathTokenError::NulByte => write!(f, "path token contains a NUL byte"),
133 PathTokenError::Colon => write!(f, "path token contains a colon"),
134 PathTokenError::Unindexable => {
135 write!(
136 f,
137 "id is too short or misaligned to form a partition prefix"
138 )
139 }
140 }
141 }
142}
143
144impl std::error::Error for PathTokenError {}
145
146pub fn validate_path_token(token: &str) -> Result<(), PathTokenError> {
158 if token.is_empty() {
159 return Err(PathTokenError::Empty);
160 }
161 if token.contains('\0') {
162 return Err(PathTokenError::NulByte);
163 }
164 if token.contains('/') || token.contains('\\') {
165 return Err(PathTokenError::Separator);
166 }
167 if token.contains(':') {
168 return Err(PathTokenError::Colon);
169 }
170 if token == ".." {
171 return Err(PathTokenError::ParentDir);
172 }
173 if token == "." {
174 return Err(PathTokenError::CurDir);
175 }
176 Ok(())
177}
178
179pub fn validate_cloud_path(cloud_path: &str) -> Result<(), PathTokenError> {
188 if cloud_path.starts_with('/') {
189 return Err(PathTokenError::Separator);
190 }
191 for segment in cloud_path.split('/') {
192 validate_path_token(segment)?;
193 }
194 Ok(())
195}
196
197const DEFAULT_STORES_DIRNAME: &str = "stores";
200const DB_FILENAME: &str = "store.db";
202
203#[derive(Clone, Debug)]
209pub struct StoreLayout {
210 app_dir: PathBuf,
211 stores_dirname: String,
212}
213
214impl StoreLayout {
215 pub fn new(app_dir: impl Into<PathBuf>) -> Self {
216 Self {
217 app_dir: app_dir.into(),
218 stores_dirname: DEFAULT_STORES_DIRNAME.to_string(),
219 }
220 }
221
222 pub fn stores_dirname(mut self, name: impl Into<String>) -> Self {
223 self.stores_dirname = name.into();
224 self
225 }
226
227 pub fn stores_root(&self) -> PathBuf {
229 self.app_dir.join(&self.stores_dirname)
230 }
231
232 pub fn pending_device_pairings_dir(&self) -> PathBuf {
233 self.app_dir.join("pending-device-pairings")
234 }
235
236 pub fn pending_device_pairing_path(&self, session_id: &str) -> Result<PathBuf, PathTokenError> {
237 validate_path_token(session_id)?;
238 Ok(self
239 .pending_device_pairings_dir()
240 .join(format!("{session_id}.json")))
241 }
242
243 pub fn pending_device_pairing_journals(&self) -> Result<Vec<(PathBuf, Vec<u8>)>, FileError> {
246 let directory = self.pending_device_pairings_dir();
247 let entries = match std::fs::read_dir(&directory) {
248 Ok(entries) => entries,
249 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
250 Err(source) => {
251 return Err(FileError::at(
252 "read pending device pairings",
253 directory,
254 source,
255 ))
256 }
257 };
258 let mut journals = Vec::new();
259 for entry in entries {
260 let entry = entry.map_err(|source| {
261 FileError::at(
262 "read pending device pairing directory entry",
263 &directory,
264 source,
265 )
266 })?;
267 let path = entry.path();
268 if crate::atomic_file::is_atomic_staging_file_name(&entry.file_name()) {
269 debug!(path = %path.display(), "ignoring unpublished device-pairing journal stage");
270 continue;
271 }
272 if !entry
273 .file_type()
274 .map_err(|source| {
275 FileError::at("read pending device pairing file type", &path, source)
276 })?
277 .is_file()
278 {
279 return Err(FileError::NotFile {
280 subject: "pending device pairing",
281 path,
282 });
283 }
284 let bytes = std::fs::read(&path)
285 .map_err(|source| FileError::at("read pending device pairing", &path, source))?;
286 journals.push((path, bytes));
287 }
288 Ok(journals)
289 }
290
291 pub fn store_dir(&self, store_id: &str) -> StoreDir {
296 StoreDir {
297 path: self.stores_root().join(store_id),
298 file_sync: crate::atomic_file::FileSync::Enabled,
299 }
300 }
301}
302
303#[derive(Clone, Debug)]
308pub struct StoreDir {
309 path: PathBuf,
310 file_sync: crate::atomic_file::FileSync,
311}
312
313impl PartialEq for StoreDir {
314 fn eq(&self, other: &Self) -> bool {
315 self.path == other.path
316 }
317}
318
319impl StoreDir {
320 pub fn new(path: impl Into<PathBuf>) -> Self {
321 Self {
322 path: path.into(),
323 file_sync: crate::atomic_file::FileSync::Enabled,
324 }
325 }
326
327 pub fn new_ephemeral(path: impl Into<PathBuf>) -> Self {
331 Self {
332 path: path.into(),
333 file_sync: crate::atomic_file::FileSync::Disabled,
334 }
335 }
336
337 #[cfg(any(test, feature = "test-utils"))]
338 pub fn new_with_file_sync_observer_for_test(
339 path: impl Into<PathBuf>,
340 ) -> (Self, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
341 let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
342 (
343 Self {
344 path: path.into(),
345 file_sync: crate::atomic_file::FileSync::ObservedDisabled(requests.clone()),
346 },
347 requests,
348 )
349 }
350
351 pub async fn stage_atomic_file(
352 &self,
353 destination: &Path,
354 ) -> Result<crate::local_file::AtomicStagedFile, FileError> {
355 crate::local_file::AtomicStagedFile::create_with_file_sync(
356 destination,
357 self.file_sync.clone(),
358 )
359 .await
360 }
361
362 pub fn create_payload_spool_stage(
363 &self,
364 ) -> Result<crate::atomic_file::AtomicFileStage, std::io::Error> {
365 crate::atomic_file::AtomicFileStage::create_in_with_file_sync(
366 &self.payload_spool_dir(),
367 self.file_sync.clone(),
368 )
369 }
370
371 pub async fn sync_parent_dir(&self, path: &Path) -> Result<(), FileError> {
372 self.file_sync.sync_parent(path).await
373 }
374
375 pub fn sync_parent_dir_blocking(&self, path: &Path) -> Result<(), FileError> {
376 self.file_sync.sync_parent_blocking(path)
377 }
378
379 pub fn db_path(&self) -> PathBuf {
380 self.path.join(DB_FILENAME)
381 }
382
383 pub fn config_path(&self) -> PathBuf {
384 self.path.join("config.yaml")
385 }
386
387 pub fn device_pairing_journal_path(&self) -> PathBuf {
388 self.path.join("device-pairing.json")
389 }
390
391 pub(crate) fn id_shard(id: &str) -> Result<String, PathTokenError> {
402 validate_path_token(id)?;
403 let hex = id.replace('-', "");
404 if !(hex.is_char_boundary(2) && hex.is_char_boundary(4)) {
405 return Err(PathTokenError::Unindexable);
406 }
407 Ok(format!("{}/{}/{id}", &hex[..2], &hex[2..4]))
408 }
409
410 pub fn hashed_path(prefix: &str, id: &str) -> Result<String, PathTokenError> {
420 validate_path_token(prefix)?;
421 Ok(format!("{prefix}/{}", Self::id_shard(id)?))
422 }
423
424 pub fn uploader_hashed_key(
433 namespace: &str,
434 uploader: &str,
435 id: &str,
436 ) -> Result<String, PathTokenError> {
437 validate_path_token(namespace)?;
438 validate_path_token(uploader)?;
439 Ok(format!("{namespace}/{uploader}/{}", Self::id_shard(id)?))
440 }
441
442 pub fn storage_dir(&self) -> PathBuf {
443 self.path.join("storage")
444 }
445
446 pub fn outbound_blob_spool_path(
450 &self,
451 locator_hash: crate::object_hash::ObjectHash,
452 ) -> PathBuf {
453 self.storage_dir()
454 .join("outbound-blobs")
455 .join(locator_hash.to_string())
456 }
457
458 pub fn payload_spool_dir(&self) -> PathBuf {
460 self.path.join("spool").join("payloads")
461 }
462
463 pub fn payload_spool_path(&self, payload_hash: crate::object_hash::ObjectHash) -> PathBuf {
470 self.payload_spool_dir().join(payload_hash.to_string())
471 }
472
473 pub async fn remove_outbound_blob_spool(
474 &self,
475 locator_hash: crate::object_hash::ObjectHash,
476 ) -> Result<(), FileError> {
477 let path = self.outbound_blob_spool_path(locator_hash);
478 match tokio::fs::remove_file(&path).await {
479 Ok(()) => self.sync_parent_dir(&path).await,
480 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
481 Err(source) => Err(FileError::at("remove exact blob spool", path, source)),
482 }
483 }
484
485 pub fn pinned_blob_path(
492 &self,
493 namespace: &str,
494 locator_hash: crate::object_hash::ObjectHash,
495 ) -> Result<PathBuf, PathTokenError> {
496 self.cache_folder_blob_path("pinned", namespace, &locator_hash.to_string())
497 }
498
499 pub async fn populate_pinned_blob_from_file(
500 &self,
501 namespace: &str,
502 locator_hash: crate::object_hash::ObjectHash,
503 expected_size: u64,
504 expected_hash: crate::object_hash::ObjectHash,
505 source: &Path,
506 ) -> Result<(), StoreBlobFileError> {
507 let destination = self
508 .pinned_blob_path(namespace, locator_hash)
509 .map_err(StoreBlobFileError::Path)?;
510 self.populate_exact_blob_from_file(destination, expected_size, expected_hash, source)
511 .await
512 }
513
514 pub async fn populate_cached_blob_from_file(
515 &self,
516 namespace: &str,
517 locator_hash: crate::object_hash::ObjectHash,
518 expected_size: u64,
519 expected_hash: crate::object_hash::ObjectHash,
520 source: &Path,
521 ) -> Result<PathBuf, StoreBlobFileError> {
522 let destination = self
523 .cache_blob_path(namespace, locator_hash)
524 .map_err(StoreBlobFileError::Path)?;
525 self.populate_exact_blob_from_file(
526 destination.clone(),
527 expected_size,
528 expected_hash,
529 source,
530 )
531 .await?;
532 Ok(destination)
533 }
534
535 async fn populate_exact_blob_from_file(
536 &self,
537 destination: PathBuf,
538 expected_size: u64,
539 expected_hash: crate::object_hash::ObjectHash,
540 source: &Path,
541 ) -> Result<(), StoreBlobFileError> {
542 let staged = self
543 .stage_atomic_file(&destination)
544 .await
545 .map_err(StoreBlobFileError::File)?;
546 let (staged, actual_size, actual_digest) = staged
547 .copy_from(source)
548 .await
549 .map_err(StoreBlobFileError::File)?;
550 let actual_hash = crate::object_hash::ObjectHash::from_digest(actual_digest);
551 if actual_size != expected_size || actual_hash != expected_hash {
552 return Err(StoreBlobFileError::Integrity {
553 path: source.to_path_buf(),
554 expected_size,
555 actual_size,
556 expected_hash,
557 actual_hash,
558 });
559 }
560 match staged.commit_new().await {
561 Ok(()) => Ok(()),
562 Err(crate::local_file::CommitNewFileError::DestinationExists(path)) => {
563 let (actual_size, actual_hash) = exact_file_facts(&path)
564 .await
565 .map_err(StoreBlobFileError::File)?;
566 if actual_size == expected_size && actual_hash == expected_hash {
567 Ok(())
568 } else {
569 Err(StoreBlobFileError::Integrity {
570 path,
571 expected_size,
572 actual_size,
573 expected_hash,
574 actual_hash,
575 })
576 }
577 }
578 Err(error) => Err(StoreBlobFileError::Commit(error)),
579 }
580 }
581
582 pub async fn pinned_blob_is_exact(
583 &self,
584 namespace: &str,
585 locator_hash: crate::object_hash::ObjectHash,
586 expected_size: u64,
587 expected_hash: crate::object_hash::ObjectHash,
588 ) -> Result<bool, StoreBlobFileError> {
589 let path = self
590 .pinned_blob_path(namespace, locator_hash)
591 .map_err(StoreBlobFileError::Path)?;
592 match file_exists(&path).await {
593 Ok(false) => Ok(false),
594 Err(error) => Err(StoreBlobFileError::File(error)),
595 Ok(true) => {
596 let (actual_size, actual_hash) = exact_file_facts(&path)
597 .await
598 .map_err(StoreBlobFileError::File)?;
599 if actual_size == expected_size && actual_hash == expected_hash {
600 Ok(true)
601 } else {
602 Err(StoreBlobFileError::Integrity {
603 path,
604 expected_size,
605 actual_size,
606 expected_hash,
607 actual_hash,
608 })
609 }
610 }
611 }
612 }
613
614 pub async fn remote_blob_is_exact(
615 &self,
616 namespace: &str,
617 locator_hash: crate::object_hash::ObjectHash,
618 expected_size: u64,
619 expected_hash: crate::object_hash::ObjectHash,
620 ) -> Result<bool, StoreBlobFileError> {
621 for path in [
622 self.pinned_blob_path(namespace, locator_hash)?,
623 self.cache_blob_path(namespace, locator_hash)?,
624 ] {
625 if file_is_exact(&path, expected_size, expected_hash).await? {
626 return Ok(true);
627 }
628 }
629 Ok(false)
630 }
631
632 pub async fn cached_blob_is_exact(
633 &self,
634 namespace: &str,
635 locator_hash: crate::object_hash::ObjectHash,
636 expected_size: u64,
637 expected_hash: crate::object_hash::ObjectHash,
638 ) -> Result<bool, StoreBlobFileError> {
639 let path = self.cache_blob_path(namespace, locator_hash)?;
640 file_is_exact(&path, expected_size, expected_hash).await
641 }
642
643 pub fn cache_blob_path(
650 &self,
651 namespace: &str,
652 locator_hash: crate::object_hash::ObjectHash,
653 ) -> Result<PathBuf, PathTokenError> {
654 self.cache_folder_blob_path("cache", namespace, &locator_hash.to_string())
655 }
656
657 pub fn remote_blob_paths(
658 &self,
659 namespace: &str,
660 locator_hash: crate::object_hash::ObjectHash,
661 ) -> Result<(PathBuf, PathBuf), PathTokenError> {
662 Ok((
663 self.pinned_blob_path(namespace, locator_hash)?,
664 self.cache_blob_path(namespace, locator_hash)?,
665 ))
666 }
667
668 pub async fn remove_cached_locator(
669 &self,
670 namespace: &str,
671 locator_hash: crate::object_hash::ObjectHash,
672 ) -> Result<(), CachedLocatorRemovalError> {
673 for path in [
674 self.pinned_blob_path(namespace, locator_hash)
675 .map_err(CachedLocatorRemovalError::Path)?,
676 self.cache_blob_path(namespace, locator_hash)
677 .map_err(CachedLocatorRemovalError::Path)?,
678 ] {
679 remove_file(&path)
680 .await
681 .map_err(CachedLocatorRemovalError::File)?;
682 }
683 Ok(())
684 }
685
686 fn cache_folder_blob_path(
693 &self,
694 folder: &str,
695 namespace: &str,
696 id: &str,
697 ) -> Result<PathBuf, PathTokenError> {
698 Ok(self
699 .cache_folder_namespace_dir(folder, namespace)?
700 .join(Self::id_shard(id)?))
701 }
702
703 fn cache_folder_namespace_dir(
708 &self,
709 folder: &str,
710 namespace: &str,
711 ) -> Result<PathBuf, PathTokenError> {
712 validate_path_token(namespace)?;
713 Ok(self.storage_dir().join(folder).join(namespace))
714 }
715
716 pub fn local_blob_path(&self, namespace: &str, id: &str) -> Result<PathBuf, PathTokenError> {
724 validate_path_token(namespace)?;
725 validate_path_token(id)?;
726 Ok(self
727 .path
728 .join("storage")
729 .join("local")
730 .join(namespace)
731 .join(id))
732 }
733
734 pub async fn require_local_blob_path(
735 &self,
736 namespace: &str,
737 id: &str,
738 ) -> Result<PathBuf, RequiredLocalBlobPathError> {
739 let path = self
740 .local_blob_path(namespace, id)
741 .map_err(RequiredLocalBlobPathError::Path)?;
742 match file_exists(&path).await {
743 Ok(true) => Ok(path),
744 Ok(false) => Err(RequiredLocalBlobPathError::Missing {
745 namespace: namespace.to_string(),
746 id: id.to_string(),
747 }),
748 Err(error) => Err(RequiredLocalBlobPathError::File(error)),
749 }
750 }
751
752 pub async fn local_blob_path_if_present(
753 &self,
754 namespace: &str,
755 id: &str,
756 expected_size: u64,
757 ) -> Result<Option<PathBuf>, LocalBlobStoreError> {
758 let path = self.local_blob_path(namespace, id)?;
759 if !file_exists(&path)
760 .await
761 .map_err(LocalBlobStoreError::File)?
762 {
763 return Ok(None);
764 }
765 let actual_size = tokio::fs::metadata(&path)
766 .await
767 .map_err(|source| {
768 LocalBlobStoreError::File(FileError::at("stat local blob", &path, source))
769 })?
770 .len();
771 if actual_size != expected_size {
772 return Err(LocalBlobStoreError::SizeMismatch {
773 path,
774 expected_size,
775 actual_size,
776 });
777 }
778 Ok(Some(path))
779 }
780
781 pub async fn remove_local_blob(
782 &self,
783 namespace: &str,
784 id: &str,
785 ) -> Result<bool, LocalBlobRemovalError> {
786 let path = self
787 .local_blob_path(namespace, id)
788 .map_err(LocalBlobRemovalError::Path)?;
789 remove_file(&path)
790 .await
791 .map_err(LocalBlobRemovalError::File)
792 }
793
794 pub fn cache_dir(&self) -> PathBuf {
798 self.storage_dir().join("cache")
799 }
800
801 fn cache_namespace_dir(&self, namespace: &str) -> Result<PathBuf, PathTokenError> {
806 self.cache_folder_namespace_dir("cache", namespace)
807 }
808
809 pub async fn cached_blob_files(
810 &self,
811 namespace: &str,
812 ) -> Result<Vec<CachedBlobFile>, StoreBlobFileError> {
813 let directory = self
814 .cache_namespace_dir(namespace)
815 .map_err(StoreBlobFileError::Path)?;
816 walk_files(&directory)
817 .await
818 .map_err(StoreBlobFileError::File)
819 .map(|files| {
820 files
821 .into_iter()
822 .map(|(path, recency, size)| CachedBlobFile {
823 path,
824 recency,
825 size,
826 })
827 .collect()
828 })
829 }
830
831 pub async fn remove_cached_blob_file(
832 &self,
833 file: &CachedBlobFile,
834 ) -> Result<bool, StoreBlobFileError> {
835 remove_file(file.path())
836 .await
837 .map_err(StoreBlobFileError::File)
838 }
839
840 pub fn remove_orphaned_write_temps(
844 &self,
845 process_start: std::time::SystemTime,
846 ) -> std::io::Result<()> {
847 let storage = self.storage_dir();
848 for directory in [
849 storage.join("local"),
850 storage.join("cache"),
851 storage.join("pinned"),
852 self.payload_spool_dir(),
853 ] {
854 self.remove_orphaned_temps_in_dir(&directory, process_start)?;
855 }
856 Ok(())
857 }
858
859 fn remove_orphaned_temps_in_dir(
860 &self,
861 dir: &Path,
862 process_start: std::time::SystemTime,
863 ) -> std::io::Result<()> {
864 let entries = match std::fs::read_dir(dir) {
865 Ok(entries) => entries,
866 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
867 debug!(
868 path = %dir.display(),
869 store_dir = %self.display(),
870 "blob directory absent during orphaned temp cleanup"
871 );
872 return Ok(());
873 }
874 Err(error) => return Err(error),
875 };
876 for entry in entries {
877 let entry = entry?;
878 let path = entry.path();
879 let file_type = entry.file_type()?;
880 if file_type.is_dir() {
881 self.remove_orphaned_temps_in_dir(&path, process_start)?;
882 } else if file_type.is_file()
883 && crate::local_file::AtomicStagedFile::is_staging_path(&path)
884 {
885 let modified = entry.metadata()?.modified()?;
886 if modified >= process_start {
887 debug!(
888 path = %path.display(),
889 "leaving fresh blob temp created at or after process start"
890 );
891 continue;
892 }
893 match std::fs::remove_file(&path) {
894 Ok(()) => {}
895 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
896 debug!(
897 path = %path.display(),
898 "file already absent during local blob cleanup"
899 );
900 }
901 Err(error) => return Err(error),
902 }
903 } else if file_type.is_file()
904 && path.file_name().and_then(|name| name.to_str()).is_none()
905 {
906 debug!(
907 path = %path.display(),
908 "skipping blob path with non-utf8 file name during orphaned temp cleanup"
909 );
910 }
911 }
912 Ok(())
913 }
914
915 pub fn ensure_created(&self) -> std::io::Result<()> {
917 std::fs::create_dir_all(&self.path)
918 }
919
920 pub fn remove_tree(&self) -> std::io::Result<()> {
923 match std::fs::remove_dir_all(&self.path) {
924 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error),
925 _ => Ok(()),
926 }
927 }
928
929 #[cfg(any(test, feature = "test-utils"))]
930 pub async fn store_local_blob(
931 &self,
932 namespace: &str,
933 id: &str,
934 bytes: &[u8],
935 ) -> Result<(), LocalBlobStoreError> {
936 let destination = self.local_blob_path(namespace, id)?;
937 let mut staged = self
938 .stage_atomic_file(&destination)
939 .await
940 .map_err(LocalBlobStoreError::File)?;
941 staged
942 .write_bytes(bytes)
943 .await
944 .map_err(LocalBlobStoreError::File)?;
945 staged.commit().await.map_err(LocalBlobStoreError::File)
946 }
947
948 #[cfg(any(test, feature = "test-utils"))]
949 pub async fn read_local_blob(
950 &self,
951 namespace: &str,
952 id: &str,
953 expected_size: u64,
954 ) -> Result<Option<Vec<u8>>, LocalBlobStoreError> {
955 let Some(path) = self
956 .local_blob_path_if_present(namespace, id, expected_size)
957 .await?
958 else {
959 return Ok(None);
960 };
961 tokio::fs::read(&path).await.map(Some).map_err(|source| {
962 LocalBlobStoreError::File(FileError::at("read local blob", path, source))
963 })
964 }
965}
966
967async fn file_exists(path: &Path) -> Result<bool, FileError> {
968 match tokio::fs::metadata(path).await {
969 Ok(metadata) => Ok(metadata.is_file()),
970 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
971 Err(source) => Err(FileError::at("stat store blob", path, source)),
972 }
973}
974
975async fn exact_file_facts(path: &Path) -> Result<(u64, crate::object_hash::ObjectHash), FileError> {
976 use sha2::{Digest, Sha256};
977 use tokio::io::AsyncReadExt;
978
979 let mut file = tokio::fs::File::open(path)
980 .await
981 .map_err(|source| FileError::at("open store blob", path, source))?;
982 let mut size = 0_u64;
983 let mut hasher = Sha256::new();
984 let mut buffer = vec![0_u8; 1 << 20];
985 loop {
986 let read = file
987 .read(&mut buffer)
988 .await
989 .map_err(|source| FileError::at("read store blob", path, source))?;
990 if read == 0 {
991 break;
992 }
993 size = size
994 .checked_add(read as u64)
995 .ok_or_else(|| FileError::SizeOverflow {
996 subject: "store blob",
997 path: path.to_path_buf(),
998 })?;
999 hasher.update(&buffer[..read]);
1000 }
1001 Ok((
1002 size,
1003 crate::object_hash::ObjectHash::from_digest(hasher.finalize().into()),
1004 ))
1005}
1006
1007async fn file_is_exact(
1008 path: &Path,
1009 expected_size: u64,
1010 expected_hash: crate::object_hash::ObjectHash,
1011) -> Result<bool, StoreBlobFileError> {
1012 if !file_exists(path).await.map_err(StoreBlobFileError::File)? {
1013 return Ok(false);
1014 }
1015 let (size, hash) = exact_file_facts(path)
1016 .await
1017 .map_err(StoreBlobFileError::File)?;
1018 Ok(size == expected_size && hash == expected_hash)
1019}
1020
1021async fn remove_file(path: &Path) -> Result<bool, FileError> {
1022 match tokio::fs::remove_file(path).await {
1023 Ok(()) => Ok(true),
1024 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1025 Err(source) => Err(FileError::at("remove store blob", path, source)),
1026 }
1027}
1028
1029async fn walk_files(path: &Path) -> Result<Vec<(PathBuf, u64, u64)>, FileError> {
1030 let mut files = Vec::new();
1031 let mut pending = vec![path.to_path_buf()];
1032 while let Some(directory) = pending.pop() {
1033 let mut entries = match tokio::fs::read_dir(&directory).await {
1034 Ok(entries) => entries,
1035 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1036 Err(source) => {
1037 return Err(FileError::at(
1038 "read store blob directory",
1039 directory,
1040 source,
1041 ))
1042 }
1043 };
1044 while let Some(entry) = entries.next_entry().await.map_err(|source| {
1045 FileError::at("read store blob directory entry", &directory, source)
1046 })? {
1047 let entry_path = entry.path();
1048 let metadata = match entry.metadata().await {
1049 Ok(metadata) => metadata,
1050 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1051 Err(source) => return Err(FileError::at("stat store blob", entry_path, source)),
1052 };
1053 if metadata.is_dir() {
1054 pending.push(entry_path);
1055 } else if !crate::local_file::AtomicStagedFile::is_staging_path(&entry_path) {
1056 let recency = metadata
1057 .modified()
1058 .map_err(|source| {
1059 FileError::at("read store blob modification time", &entry_path, source)
1060 })?
1061 .duration_since(std::time::UNIX_EPOCH)
1062 .map_err(|source| FileError::ModifiedBeforeUnixEpoch {
1063 path: entry_path.clone(),
1064 source,
1065 })?
1066 .as_millis() as u64;
1067 files.push((entry_path, recency, metadata.len()));
1068 }
1069 }
1070 }
1071 Ok(files)
1072}
1073
1074pub struct StoreOpenGuard {
1095 _file: std::fs::File,
1096}
1097
1098#[derive(Debug, thiserror::Error)]
1099pub enum StoreOpenGuardError {
1100 #[error("store is already open: {}", store_dir.display())]
1101 AlreadyOpen { store_dir: PathBuf },
1102 #[error("store database path has no parent: {}", path.display())]
1103 NoParent { path: PathBuf },
1104 #[error("store lock file: {0}")]
1105 File(#[from] FileError),
1106}
1107
1108impl StoreOpenGuard {
1109 pub fn acquire(store_dir: &StoreDir) -> Result<Self, StoreOpenGuardError> {
1110 let db_path = store_dir.db_path();
1111 let Some(dir) = db_path.parent() else {
1112 return Err(StoreOpenGuardError::NoParent { path: db_path });
1113 };
1114 std::fs::create_dir_all(dir).map_err(|source| {
1115 StoreOpenGuardError::File(FileError::at("create store directory", dir, source))
1116 })?;
1117 let lock_path = dir.join(".coven-lock");
1118 let file = std::fs::OpenOptions::new()
1119 .read(true)
1120 .write(true)
1121 .create(true)
1122 .truncate(false)
1123 .open(&lock_path)
1124 .map_err(|source| {
1125 StoreOpenGuardError::File(FileError::at("open store lock", &lock_path, source))
1126 })?;
1127 match Self::try_lock_exclusive(&file) {
1128 Ok(()) => Ok(Self { _file: file }),
1129 Err(std::fs::TryLockError::WouldBlock) => Err(StoreOpenGuardError::AlreadyOpen {
1130 store_dir: dir.to_path_buf(),
1131 }),
1132 Err(std::fs::TryLockError::Error(source)) => Err(StoreOpenGuardError::File(
1133 FileError::at("lock store", lock_path, source),
1134 )),
1135 }
1136 }
1137
1138 #[cfg(not(target_os = "android"))]
1139 fn try_lock_exclusive(file: &std::fs::File) -> Result<(), std::fs::TryLockError> {
1140 file.try_lock()
1141 }
1142
1143 #[cfg(target_os = "android")]
1147 fn try_lock_exclusive(file: &std::fs::File) -> Result<(), std::fs::TryLockError> {
1148 rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive).map_err(
1149 |errno| {
1150 if errno == rustix::io::Errno::WOULDBLOCK {
1151 std::fs::TryLockError::WouldBlock
1152 } else {
1153 std::fs::TryLockError::Error(errno.into())
1154 }
1155 },
1156 )
1157 }
1158
1159 #[cfg(any(test, feature = "test-utils"))]
1161 pub fn acquire_for_test(store_dir: &StoreDir) -> std::sync::Arc<Self> {
1162 std::sync::Arc::new(Self::acquire(store_dir).expect("acquire store open guard"))
1163 }
1164}
1165
1166impl Deref for StoreDir {
1167 type Target = Path;
1168
1169 fn deref(&self) -> &Path {
1170 &self.path
1171 }
1172}
1173
1174impl AsRef<Path> for StoreDir {
1175 fn as_ref(&self) -> &Path {
1176 &self.path
1177 }
1178}
1179
1180impl From<PathBuf> for StoreDir {
1181 fn from(path: PathBuf) -> Self {
1182 Self::new(path)
1183 }
1184}
1185
1186#[cfg(any(test, feature = "test-utils"))]
1189pub fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
1190 let tmp = tempfile::tempdir().expect("temp dir");
1191 let dir = StoreDir::new_ephemeral(tmp.path());
1192 (tmp, dir)
1193}
1194
1195#[cfg(test)]
1196#[path = "store_dir_tests.rs"]
1197mod tests;