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> {
401 validate_path_token(id)?;
402 let hex = id.replace('-', "");
403 if !(hex.is_char_boundary(2) && hex.is_char_boundary(4)) {
404 return Err(PathTokenError::Unindexable);
405 }
406 Ok(format!("{}/{}/{id}", &hex[..2], &hex[2..4]))
407 }
408
409 pub fn storage_dir(&self) -> PathBuf {
410 self.path.join("storage")
411 }
412
413 pub fn outbound_blob_spool_path(
417 &self,
418 locator_hash: crate::object_hash::ObjectHash,
419 ) -> PathBuf {
420 self.storage_dir()
421 .join("outbound-blobs")
422 .join(locator_hash.to_string())
423 }
424
425 pub fn payload_spool_dir(&self) -> PathBuf {
427 self.path.join("spool").join("payloads")
428 }
429
430 pub fn payload_spool_path(&self, payload_hash: crate::object_hash::ObjectHash) -> PathBuf {
437 self.payload_spool_dir().join(payload_hash.to_string())
438 }
439
440 pub async fn remove_outbound_blob_spool(
441 &self,
442 locator_hash: crate::object_hash::ObjectHash,
443 ) -> Result<(), FileError> {
444 let path = self.outbound_blob_spool_path(locator_hash);
445 match tokio::fs::remove_file(&path).await {
446 Ok(()) => self.sync_parent_dir(&path).await,
447 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
448 Err(source) => Err(FileError::at("remove exact blob spool", path, source)),
449 }
450 }
451
452 pub fn pinned_blob_path(
459 &self,
460 namespace: &str,
461 locator_hash: crate::object_hash::ObjectHash,
462 ) -> Result<PathBuf, PathTokenError> {
463 self.cache_folder_blob_path("pinned", namespace, &locator_hash.to_string())
464 }
465
466 pub async fn populate_pinned_blob_from_file(
467 &self,
468 namespace: &str,
469 locator_hash: crate::object_hash::ObjectHash,
470 expected_size: u64,
471 expected_hash: crate::object_hash::ObjectHash,
472 source: &Path,
473 ) -> Result<(), StoreBlobFileError> {
474 let destination = self
475 .pinned_blob_path(namespace, locator_hash)
476 .map_err(StoreBlobFileError::Path)?;
477 self.populate_exact_blob_from_file(destination, expected_size, expected_hash, source)
478 .await
479 }
480
481 pub async fn populate_cached_blob_from_file(
482 &self,
483 namespace: &str,
484 locator_hash: crate::object_hash::ObjectHash,
485 expected_size: u64,
486 expected_hash: crate::object_hash::ObjectHash,
487 source: &Path,
488 ) -> Result<PathBuf, StoreBlobFileError> {
489 let destination = self
490 .cache_blob_path(namespace, locator_hash)
491 .map_err(StoreBlobFileError::Path)?;
492 self.populate_exact_blob_from_file(
493 destination.clone(),
494 expected_size,
495 expected_hash,
496 source,
497 )
498 .await?;
499 Ok(destination)
500 }
501
502 async fn populate_exact_blob_from_file(
503 &self,
504 destination: PathBuf,
505 expected_size: u64,
506 expected_hash: crate::object_hash::ObjectHash,
507 source: &Path,
508 ) -> Result<(), StoreBlobFileError> {
509 let staged = self
510 .stage_atomic_file(&destination)
511 .await
512 .map_err(StoreBlobFileError::File)?;
513 let (staged, actual_size, actual_digest) = staged
514 .copy_from(source)
515 .await
516 .map_err(StoreBlobFileError::File)?;
517 let actual_hash = crate::object_hash::ObjectHash::from_digest(actual_digest);
518 if actual_size != expected_size || actual_hash != expected_hash {
519 return Err(StoreBlobFileError::Integrity {
520 path: source.to_path_buf(),
521 expected_size,
522 actual_size,
523 expected_hash,
524 actual_hash,
525 });
526 }
527 match staged.commit_new().await {
528 Ok(()) => Ok(()),
529 Err(crate::local_file::CommitNewFileError::DestinationExists(path)) => {
530 let (actual_size, actual_hash) = exact_file_facts(&path)
531 .await
532 .map_err(StoreBlobFileError::File)?;
533 if actual_size == expected_size && actual_hash == expected_hash {
534 Ok(())
535 } else {
536 Err(StoreBlobFileError::Integrity {
537 path,
538 expected_size,
539 actual_size,
540 expected_hash,
541 actual_hash,
542 })
543 }
544 }
545 Err(error) => Err(StoreBlobFileError::Commit(error)),
546 }
547 }
548
549 pub async fn pinned_blob_is_exact(
550 &self,
551 namespace: &str,
552 locator_hash: crate::object_hash::ObjectHash,
553 expected_size: u64,
554 expected_hash: crate::object_hash::ObjectHash,
555 ) -> Result<bool, StoreBlobFileError> {
556 let path = self
557 .pinned_blob_path(namespace, locator_hash)
558 .map_err(StoreBlobFileError::Path)?;
559 match file_exists(&path).await {
560 Ok(false) => Ok(false),
561 Err(error) => Err(StoreBlobFileError::File(error)),
562 Ok(true) => {
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(true)
568 } else {
569 Err(StoreBlobFileError::Integrity {
570 path,
571 expected_size,
572 actual_size,
573 expected_hash,
574 actual_hash,
575 })
576 }
577 }
578 }
579 }
580
581 pub async fn remote_blob_is_exact(
582 &self,
583 namespace: &str,
584 locator_hash: crate::object_hash::ObjectHash,
585 expected_size: u64,
586 expected_hash: crate::object_hash::ObjectHash,
587 ) -> Result<bool, StoreBlobFileError> {
588 for path in [
589 self.pinned_blob_path(namespace, locator_hash)?,
590 self.cache_blob_path(namespace, locator_hash)?,
591 ] {
592 if file_is_exact(&path, expected_size, expected_hash).await? {
593 return Ok(true);
594 }
595 }
596 Ok(false)
597 }
598
599 pub async fn cached_blob_is_exact(
600 &self,
601 namespace: &str,
602 locator_hash: crate::object_hash::ObjectHash,
603 expected_size: u64,
604 expected_hash: crate::object_hash::ObjectHash,
605 ) -> Result<bool, StoreBlobFileError> {
606 let path = self.cache_blob_path(namespace, locator_hash)?;
607 file_is_exact(&path, expected_size, expected_hash).await
608 }
609
610 pub fn cache_blob_path(
617 &self,
618 namespace: &str,
619 locator_hash: crate::object_hash::ObjectHash,
620 ) -> Result<PathBuf, PathTokenError> {
621 self.cache_folder_blob_path("cache", namespace, &locator_hash.to_string())
622 }
623
624 pub fn remote_blob_paths(
625 &self,
626 namespace: &str,
627 locator_hash: crate::object_hash::ObjectHash,
628 ) -> Result<(PathBuf, PathBuf), PathTokenError> {
629 Ok((
630 self.pinned_blob_path(namespace, locator_hash)?,
631 self.cache_blob_path(namespace, locator_hash)?,
632 ))
633 }
634
635 pub async fn remove_cached_locator(
636 &self,
637 namespace: &str,
638 locator_hash: crate::object_hash::ObjectHash,
639 ) -> Result<(), CachedLocatorRemovalError> {
640 for path in [
641 self.pinned_blob_path(namespace, locator_hash)
642 .map_err(CachedLocatorRemovalError::Path)?,
643 self.cache_blob_path(namespace, locator_hash)
644 .map_err(CachedLocatorRemovalError::Path)?,
645 ] {
646 remove_file(&path)
647 .await
648 .map_err(CachedLocatorRemovalError::File)?;
649 }
650 Ok(())
651 }
652
653 fn cache_folder_blob_path(
660 &self,
661 folder: &str,
662 namespace: &str,
663 id: &str,
664 ) -> Result<PathBuf, PathTokenError> {
665 Ok(self
666 .cache_folder_namespace_dir(folder, namespace)?
667 .join(Self::id_shard(id)?))
668 }
669
670 fn cache_folder_namespace_dir(
675 &self,
676 folder: &str,
677 namespace: &str,
678 ) -> Result<PathBuf, PathTokenError> {
679 validate_path_token(namespace)?;
680 Ok(self.storage_dir().join(folder).join(namespace))
681 }
682
683 pub fn local_blob_path(&self, namespace: &str, id: &str) -> Result<PathBuf, PathTokenError> {
691 validate_path_token(namespace)?;
692 validate_path_token(id)?;
693 Ok(self
694 .path
695 .join("storage")
696 .join("local")
697 .join(namespace)
698 .join(id))
699 }
700
701 pub async fn require_local_blob_path(
702 &self,
703 namespace: &str,
704 id: &str,
705 ) -> Result<PathBuf, RequiredLocalBlobPathError> {
706 let path = self
707 .local_blob_path(namespace, id)
708 .map_err(RequiredLocalBlobPathError::Path)?;
709 match file_exists(&path).await {
710 Ok(true) => Ok(path),
711 Ok(false) => Err(RequiredLocalBlobPathError::Missing {
712 namespace: namespace.to_string(),
713 id: id.to_string(),
714 }),
715 Err(error) => Err(RequiredLocalBlobPathError::File(error)),
716 }
717 }
718
719 pub async fn local_blob_path_if_present(
720 &self,
721 namespace: &str,
722 id: &str,
723 expected_size: u64,
724 ) -> Result<Option<PathBuf>, LocalBlobStoreError> {
725 let path = self.local_blob_path(namespace, id)?;
726 if !file_exists(&path)
727 .await
728 .map_err(LocalBlobStoreError::File)?
729 {
730 return Ok(None);
731 }
732 let actual_size = tokio::fs::metadata(&path)
733 .await
734 .map_err(|source| {
735 LocalBlobStoreError::File(FileError::at("stat local blob", &path, source))
736 })?
737 .len();
738 if actual_size != expected_size {
739 return Err(LocalBlobStoreError::SizeMismatch {
740 path,
741 expected_size,
742 actual_size,
743 });
744 }
745 Ok(Some(path))
746 }
747
748 pub async fn remove_local_blob(
749 &self,
750 namespace: &str,
751 id: &str,
752 ) -> Result<bool, LocalBlobRemovalError> {
753 let path = self
754 .local_blob_path(namespace, id)
755 .map_err(LocalBlobRemovalError::Path)?;
756 remove_file(&path)
757 .await
758 .map_err(LocalBlobRemovalError::File)
759 }
760
761 pub fn cache_dir(&self) -> PathBuf {
765 self.storage_dir().join("cache")
766 }
767
768 fn cache_namespace_dir(&self, namespace: &str) -> Result<PathBuf, PathTokenError> {
773 self.cache_folder_namespace_dir("cache", namespace)
774 }
775
776 pub async fn cached_blob_files(
777 &self,
778 namespace: &str,
779 ) -> Result<Vec<CachedBlobFile>, StoreBlobFileError> {
780 let directory = self
781 .cache_namespace_dir(namespace)
782 .map_err(StoreBlobFileError::Path)?;
783 walk_files(&directory)
784 .await
785 .map_err(StoreBlobFileError::File)
786 .map(|files| {
787 files
788 .into_iter()
789 .map(|(path, recency, size)| CachedBlobFile {
790 path,
791 recency,
792 size,
793 })
794 .collect()
795 })
796 }
797
798 pub async fn remove_cached_blob_file(
799 &self,
800 file: &CachedBlobFile,
801 ) -> Result<bool, StoreBlobFileError> {
802 remove_file(file.path())
803 .await
804 .map_err(StoreBlobFileError::File)
805 }
806
807 pub fn remove_orphaned_write_temps(
811 &self,
812 process_start: std::time::SystemTime,
813 ) -> std::io::Result<()> {
814 let storage = self.storage_dir();
815 for directory in [
816 storage.join("local"),
817 storage.join("cache"),
818 storage.join("pinned"),
819 self.payload_spool_dir(),
820 ] {
821 self.remove_orphaned_temps_in_dir(&directory, process_start)?;
822 }
823 Ok(())
824 }
825
826 fn remove_orphaned_temps_in_dir(
827 &self,
828 dir: &Path,
829 process_start: std::time::SystemTime,
830 ) -> std::io::Result<()> {
831 let entries = match std::fs::read_dir(dir) {
832 Ok(entries) => entries,
833 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
834 debug!(
835 path = %dir.display(),
836 store_dir = %self.display(),
837 "blob directory absent during orphaned temp cleanup"
838 );
839 return Ok(());
840 }
841 Err(error) => return Err(error),
842 };
843 for entry in entries {
844 let entry = entry?;
845 let path = entry.path();
846 let file_type = entry.file_type()?;
847 if file_type.is_dir() {
848 self.remove_orphaned_temps_in_dir(&path, process_start)?;
849 } else if file_type.is_file()
850 && crate::local_file::AtomicStagedFile::is_staging_path(&path)
851 {
852 let modified = entry.metadata()?.modified()?;
853 if modified >= process_start {
854 debug!(
855 path = %path.display(),
856 "leaving fresh blob temp created at or after process start"
857 );
858 continue;
859 }
860 match std::fs::remove_file(&path) {
861 Ok(()) => {}
862 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
863 debug!(
864 path = %path.display(),
865 "file already absent during local blob cleanup"
866 );
867 }
868 Err(error) => return Err(error),
869 }
870 } else if file_type.is_file()
871 && path.file_name().and_then(|name| name.to_str()).is_none()
872 {
873 debug!(
874 path = %path.display(),
875 "skipping blob path with non-utf8 file name during orphaned temp cleanup"
876 );
877 }
878 }
879 Ok(())
880 }
881
882 pub fn ensure_created(&self) -> std::io::Result<()> {
884 std::fs::create_dir_all(&self.path)
885 }
886
887 pub fn remove_tree(&self) -> std::io::Result<()> {
890 match std::fs::remove_dir_all(&self.path) {
891 Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error),
892 _ => Ok(()),
893 }
894 }
895
896 #[cfg(any(test, feature = "test-utils"))]
897 pub async fn store_local_blob(
898 &self,
899 namespace: &str,
900 id: &str,
901 bytes: &[u8],
902 ) -> Result<(), LocalBlobStoreError> {
903 let destination = self.local_blob_path(namespace, id)?;
904 let mut staged = self
905 .stage_atomic_file(&destination)
906 .await
907 .map_err(LocalBlobStoreError::File)?;
908 staged
909 .write_bytes(bytes)
910 .await
911 .map_err(LocalBlobStoreError::File)?;
912 staged.commit().await.map_err(LocalBlobStoreError::File)
913 }
914
915 #[cfg(any(test, feature = "test-utils"))]
916 pub async fn read_local_blob(
917 &self,
918 namespace: &str,
919 id: &str,
920 expected_size: u64,
921 ) -> Result<Option<Vec<u8>>, LocalBlobStoreError> {
922 let Some(path) = self
923 .local_blob_path_if_present(namespace, id, expected_size)
924 .await?
925 else {
926 return Ok(None);
927 };
928 tokio::fs::read(&path).await.map(Some).map_err(|source| {
929 LocalBlobStoreError::File(FileError::at("read local blob", path, source))
930 })
931 }
932}
933
934async fn file_exists(path: &Path) -> Result<bool, FileError> {
935 match tokio::fs::metadata(path).await {
936 Ok(metadata) => Ok(metadata.is_file()),
937 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
938 Err(source) => Err(FileError::at("stat store blob", path, source)),
939 }
940}
941
942async fn exact_file_facts(path: &Path) -> Result<(u64, crate::object_hash::ObjectHash), FileError> {
943 use sha2::{Digest, Sha256};
944 use tokio::io::AsyncReadExt;
945
946 let mut file = tokio::fs::File::open(path)
947 .await
948 .map_err(|source| FileError::at("open store blob", path, source))?;
949 let mut size = 0_u64;
950 let mut hasher = Sha256::new();
951 let mut buffer = vec![0_u8; 1 << 20];
952 loop {
953 let read = file
954 .read(&mut buffer)
955 .await
956 .map_err(|source| FileError::at("read store blob", path, source))?;
957 if read == 0 {
958 break;
959 }
960 size = size
961 .checked_add(read as u64)
962 .ok_or_else(|| FileError::SizeOverflow {
963 subject: "store blob",
964 path: path.to_path_buf(),
965 })?;
966 hasher.update(&buffer[..read]);
967 }
968 Ok((
969 size,
970 crate::object_hash::ObjectHash::from_digest(hasher.finalize().into()),
971 ))
972}
973
974async fn file_is_exact(
975 path: &Path,
976 expected_size: u64,
977 expected_hash: crate::object_hash::ObjectHash,
978) -> Result<bool, StoreBlobFileError> {
979 if !file_exists(path).await.map_err(StoreBlobFileError::File)? {
980 return Ok(false);
981 }
982 let (size, hash) = exact_file_facts(path)
983 .await
984 .map_err(StoreBlobFileError::File)?;
985 Ok(size == expected_size && hash == expected_hash)
986}
987
988async fn remove_file(path: &Path) -> Result<bool, FileError> {
989 match tokio::fs::remove_file(path).await {
990 Ok(()) => Ok(true),
991 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
992 Err(source) => Err(FileError::at("remove store blob", path, source)),
993 }
994}
995
996async fn walk_files(path: &Path) -> Result<Vec<(PathBuf, u64, u64)>, FileError> {
997 let mut files = Vec::new();
998 let mut pending = vec![path.to_path_buf()];
999 while let Some(directory) = pending.pop() {
1000 let mut entries = match tokio::fs::read_dir(&directory).await {
1001 Ok(entries) => entries,
1002 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1003 Err(source) => {
1004 return Err(FileError::at(
1005 "read store blob directory",
1006 directory,
1007 source,
1008 ))
1009 }
1010 };
1011 while let Some(entry) = entries.next_entry().await.map_err(|source| {
1012 FileError::at("read store blob directory entry", &directory, source)
1013 })? {
1014 let entry_path = entry.path();
1015 let metadata = match entry.metadata().await {
1016 Ok(metadata) => metadata,
1017 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1018 Err(source) => return Err(FileError::at("stat store blob", entry_path, source)),
1019 };
1020 if metadata.is_dir() {
1021 pending.push(entry_path);
1022 } else if !crate::local_file::AtomicStagedFile::is_staging_path(&entry_path) {
1023 let recency = metadata
1024 .modified()
1025 .map_err(|source| {
1026 FileError::at("read store blob modification time", &entry_path, source)
1027 })?
1028 .duration_since(std::time::UNIX_EPOCH)
1029 .map_err(|source| FileError::ModifiedBeforeUnixEpoch {
1030 path: entry_path.clone(),
1031 source,
1032 })?
1033 .as_millis() as u64;
1034 files.push((entry_path, recency, metadata.len()));
1035 }
1036 }
1037 }
1038 Ok(files)
1039}
1040
1041pub struct StoreOpenGuard {
1062 _file: std::fs::File,
1063}
1064
1065#[derive(Debug, thiserror::Error)]
1066pub enum StoreOpenGuardError {
1067 #[error("store is already open: {}", store_dir.display())]
1068 AlreadyOpen { store_dir: PathBuf },
1069 #[error("store database path has no parent: {}", path.display())]
1070 NoParent { path: PathBuf },
1071 #[error("store lock file: {0}")]
1072 File(#[from] FileError),
1073}
1074
1075impl StoreOpenGuard {
1076 pub fn acquire(store_dir: &StoreDir) -> Result<Self, StoreOpenGuardError> {
1077 let db_path = store_dir.db_path();
1078 let Some(dir) = db_path.parent() else {
1079 return Err(StoreOpenGuardError::NoParent { path: db_path });
1080 };
1081 std::fs::create_dir_all(dir).map_err(|source| {
1082 StoreOpenGuardError::File(FileError::at("create store directory", dir, source))
1083 })?;
1084 let lock_path = dir.join(".coven-lock");
1085 let file = std::fs::OpenOptions::new()
1086 .read(true)
1087 .write(true)
1088 .create(true)
1089 .truncate(false)
1090 .open(&lock_path)
1091 .map_err(|source| {
1092 StoreOpenGuardError::File(FileError::at("open store lock", &lock_path, source))
1093 })?;
1094 match Self::try_lock_exclusive(&file) {
1095 Ok(()) => Ok(Self { _file: file }),
1096 Err(std::fs::TryLockError::WouldBlock) => Err(StoreOpenGuardError::AlreadyOpen {
1097 store_dir: dir.to_path_buf(),
1098 }),
1099 Err(std::fs::TryLockError::Error(source)) => Err(StoreOpenGuardError::File(
1100 FileError::at("lock store", lock_path, source),
1101 )),
1102 }
1103 }
1104
1105 #[cfg(not(target_os = "android"))]
1106 fn try_lock_exclusive(file: &std::fs::File) -> Result<(), std::fs::TryLockError> {
1107 file.try_lock()
1108 }
1109
1110 #[cfg(target_os = "android")]
1114 fn try_lock_exclusive(file: &std::fs::File) -> Result<(), std::fs::TryLockError> {
1115 rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive).map_err(
1116 |errno| {
1117 if errno == rustix::io::Errno::WOULDBLOCK {
1118 std::fs::TryLockError::WouldBlock
1119 } else {
1120 std::fs::TryLockError::Error(errno.into())
1121 }
1122 },
1123 )
1124 }
1125
1126 #[cfg(any(test, feature = "test-utils"))]
1128 pub fn acquire_for_test(store_dir: &StoreDir) -> std::sync::Arc<Self> {
1129 std::sync::Arc::new(Self::acquire(store_dir).expect("acquire store open guard"))
1130 }
1131}
1132
1133impl Deref for StoreDir {
1134 type Target = Path;
1135
1136 fn deref(&self) -> &Path {
1137 &self.path
1138 }
1139}
1140
1141impl AsRef<Path> for StoreDir {
1142 fn as_ref(&self) -> &Path {
1143 &self.path
1144 }
1145}
1146
1147impl From<PathBuf> for StoreDir {
1148 fn from(path: PathBuf) -> Self {
1149 Self::new(path)
1150 }
1151}
1152
1153#[cfg(any(test, feature = "test-utils"))]
1156pub fn temp_store_dir() -> (tempfile::TempDir, StoreDir) {
1157 let tmp = tempfile::tempdir().expect("temp dir");
1158 let dir = StoreDir::new_ephemeral(tmp.path());
1159 (tmp, dir)
1160}
1161
1162#[cfg(test)]
1163#[path = "store_dir_tests.rs"]
1164mod tests;