Skip to main content

coven_foundation/
store_dir.rs

1use std::ops::Deref;
2use std::path::{Path, PathBuf};
3use tracing::debug;
4
5use crate::atomic_file::FileError;
6
7/// Why a string is not a safe path token.
8///
9/// An untrusted string becomes a path component in several places: a blob's
10/// `id`/`namespace` (interpolated into its on-disk file path and cloud object
11/// key), and a `store_id`/`sid` from an untrusted device invitation or restore code (the
12/// name of a directory under `stores/`). All arrive from outside — an incoming
13/// changeset authored by any write-capable member, or a pasted code anyone can
14/// craft — so an unconstrained one could climb out of the directory it is joined
15/// onto (`..`, a path separator, an absolute leading slash) and make a pulling or
16/// joining device read, write, or recursively delete an arbitrary location, or —
17/// too short / not aligned to a char boundary — crash a blob's partition-prefix
18/// slice. A string that trips any of these is bad data, refused before a path is
19/// built or used.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum PathTokenError {
22    /// The token is empty — no file name to write, no key to form.
23    Empty,
24    /// The token contains a path separator (`/` or `\`), so joining it onto a
25    /// directory would descend into (or, with a leading separator, replace) the
26    /// path rather than name a single child.
27    Separator,
28    /// The token is exactly `..`, which names the parent of the directory it is
29    /// joined onto rather than a child. A trailing `..` component is normalized
30    /// away when the path is resolved, so the join lands on the parent.
31    ParentDir,
32    /// The token is exactly `.`, which names the directory it is joined onto
33    /// itself rather than a child. Like `..`, a trailing `.` component is
34    /// normalized away, so `stores/.` resolves to `stores`'s parent (the
35    /// data dir) — an escape just as `..` is.
36    CurDir,
37    /// The token contains a NUL byte, which truncates the path at the OS boundary.
38    NulByte,
39    /// The token contains a `:`, which on Windows names an alternate data stream
40    /// (`file:stream`) or a drive-relative reference (`c:dir`) rather than a child.
41    Colon,
42    /// The dash-stripped id is too short, or splits a multi-byte char, to take the
43    /// two leading byte-pairs the `{ab}/{cd}` partition prefix needs.
44    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
146/// Reject a single untrusted path token (a blob `id`/`namespace`, or a
147/// `store_id`/`sid`) that could escape the directory it is joined onto. A safe
148/// token names exactly one child: no separator, no `..`, no `.`, no NUL, no `:` (a
149/// Windows stream/drive reference), non-empty. The single gate every path builder
150/// and every code decoder runs an untrusted token through, so traversal is refused
151/// before any on-disk or cloud path is formed — and a decoded id is a safe single
152/// component by the time any consumer joins it onto a directory.
153///
154/// Both `.` and `..` are refused: each is a directory-relative reference that a
155/// trailing path component normalizes away, so joining either onto `dir` resolves
156/// to `dir` itself or its parent rather than to a child of `dir`.
157pub 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
179/// Reject an untrusted `cloud_path` (the consumer's readable object key under the
180/// plain scheme, e.g. `"Artist - Album/cover.jpg"`) that could escape its
181/// namespace prefix in the bucket. Unlike a path token, an interior `/` is
182/// legitimate — the readable path is nested — but every segment still has to be
183/// a canonical path token. Empty, `.`, `..`, colon/platform-prefix, backslash,
184/// and NUL forms are refused before an object key is built. The `cloud_path`
185/// never feeds a local file path, only the cloud object key, so this guards the
186/// keyspace, not the disk.
187pub 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
197/// Default name of the parent directory a store lives under — overridden
198/// per host via [`StoreLayout::stores_dirname`].
199const DEFAULT_STORES_DIRNAME: &str = "stores";
200/// The name of a store's own database file.
201const DB_FILENAME: &str = "store.db";
202
203/// The host's on-disk layout for stores: which directory they live under.
204/// One rule shared by create, open, join, and restore, so a host that wants
205/// `libraries/<id>` instead of coven's default `stores/<id>` names it once
206/// here rather than each flow hardwiring (or working around) coven's own
207/// choice.
208#[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    /// The stores parent dir (for host listing/discovery).
228    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    /// Read every committed device-pairing journal entry. Atomic-write stages
244    /// are unpublished files and therefore never enter the durable record set.
245    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    /// The one `(app_dir, store_id) -> StoreDir` rule, named with this
292    /// layout's directory. Callers validate an untrusted `store_id`
293    /// ([`validate_path_token`]) BEFORE calling, as every
294    /// join/restore/create flow already does.
295    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/// Typed wrapper for a store directory path.
304///
305/// Centralizes the on-disk layout so callers use methods instead of
306/// ad-hoc `path.join("images")` etc.
307#[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    /// A store directory whose owning database is itself ephemeral. Atomic
328    /// visibility and rollback still run, but persistence barriers do not: no
329    /// file can outlive the durable state that names it.
330    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    /// The two-level partition shard for `id`: `{ab}/{cd}/{id}`, where `{ab}`/`{cd}`
392    /// are the first two byte-pairs of the dash-stripped id. The single home for the
393    /// partition scheme — every blob path (cloud key and on-disk file, hashed or
394    /// pinned/cache) is this shard under some root.
395    ///
396    /// `id` is validated as a single path token and must be long enough (and
397    /// char-boundary aligned) to take the two leading byte-pairs. An id that fails
398    /// is bad data — it could escape the directory or crash the slice — so this
399    /// returns [`PathTokenError`] rather than interpolating it or panicking; the
400    /// caller refuses the blob.
401    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    /// Content-addressed relative path `{prefix}/{ab}/{cd}/{id}`, partitioning by
411    /// the first two byte-pairs of the dash-stripped id. The single home for the
412    /// partition scheme — shared by the local blob store and the cloud layout.
413    ///
414    /// Both `prefix` and `id` are validated as single path tokens, and the id must
415    /// be long enough (and char-boundary aligned) to take the two leading
416    /// byte-pairs the prefix needs. An id that fails is bad data — it could escape
417    /// the directory or crash the slice — so this returns [`PathTokenError`] rather
418    /// than interpolating it or panicking; the caller refuses the blob.
419    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    /// The cloud object key for a Hashed-scheme blob under the device that
425    /// uploaded it: `{namespace}/{uploader}/{ab}/{cd}/{id}`. The `{uploader}`
426    /// segment is what aligns the blob keyspace to the storage-access rule (a
427    /// member writes only under its own public key), so a bucket ACL can scope each
428    /// member to `{namespace}/{self}/`. Only the *cloud* key carries it; the local
429    /// cache keeps the un-prefixed `{namespace}/{ab}/{cd}/{id}` layout because it is
430    /// per-device. `namespace` and `uploader` are validated as single path tokens;
431    /// the id must be indexable (see `id_shard`).
432    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    /// Immutable stored bytes prepared for one blob locator. The locator hash is
447    /// the file name, so retries reopen the same exact spool rather than sealing
448    /// the plaintext again with fresh randomness.
449    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    /// The directory holding every internal payload file.
459    pub fn payload_spool_dir(&self) -> PathBuf {
460        self.path.join("spool").join("payloads")
461    }
462
463    /// The file holding one internal payload — bytes a database row owns,
464    /// stored beside the database rather than inside it. The file is named for
465    /// the digest of the bytes it holds, so a retry of a failed insert rewrites
466    /// the same path with the same contents. Unlike a blob, a payload is never
467    /// leased, packaged for an audience, or evicted: it is deleted by the flow
468    /// that deletes the row referencing it.
469    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    /// A kept (budget-exempt) cache copy of a **Remote** blob:
486    /// `storage/pinned/<namespace>/{ab}/{cd}/<locator-hash>`. The kept sibling of
487    /// [`Self::cache_blob_path`] — same per-namespace shard layout, in the `pinned`
488    /// folder instead of `cache`. The cache's truth is the folder a blob's file lives
489    /// in, not a table; a file here is a Remote blob's cache copy the user pinned for
490    /// offline (kept from eviction). `Err` if `namespace` is unsafe.
491    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    /// An opportunistic (evictable) cache copy of a **Remote** blob:
644    /// `storage/cache/<namespace>/{ab}/{cd}/<locator-hash>`. A file here is a cached-but-unpinned
645    /// blob — fetched on read or eagerly on pull, droppable by the budget sweep. The
646    /// folder it lives in, not a table, is what makes it evictable rather than kept.
647    /// Segmented by `namespace` so each namespace's budget evicts only its own
648    /// subtree, `storage/cache/<namespace>`. `Err` if `namespace` is unsafe.
649    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    /// `storage/<folder>/<namespace>/{ab}/{cd}/<locator-hash>` — the single blob-path builder
687    /// behind [`Self::cache_blob_path`] (`folder` = `cache`) and
688    /// [`Self::pinned_blob_path`] (`folder` = `pinned`), which differ only by the
689    /// folder token. Composes the per-namespace dir
690    /// ([`Self::cache_folder_namespace_dir`]) with the locator-hash shard, so the layout lives
691    /// in one place. `namespace` and the locator hash are validated.
692    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    /// `storage/<folder>/<namespace>` for a cache folder (`cache` evictable / `pinned`
704    /// kept), `namespace` validated as a single path token. The per-namespace dir both
705    /// cache folders compose onto; [`Self::cache_namespace_dir`] is the evictable case
706    /// the budget sweep walks.
707    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    /// coven's own copy of a **host-provided Local** blob:
717    /// `storage/local/<namespace>/<id>`. This is NOT a cache copy — it is the blob's
718    /// home while its release is Local (a host-provided blob has no user path). It
719    /// is never evicted: the budget sweep walks only [`Self::cache_dir`], never
720    /// `storage/local`. Both `namespace` and `id` are validated as single path
721    /// tokens (the blob columns come from a row any write-capable member authored),
722    /// so neither can escape the store. `Err` if either is unsafe.
723    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    /// The evictable-cache root, `storage/cache`, holding every namespace's subtree.
795    /// The per-namespace budget sweep walks only one namespace's subtree under it,
796    /// `storage/cache/<namespace>`.
797    pub fn cache_dir(&self) -> PathBuf {
798        self.storage_dir().join("cache")
799    }
800
801    /// One namespace's evictable-cache subtree, `storage/cache/<namespace>`. The
802    /// cache budget enforcement walks only this tree, so
803    /// a namespace evicts against its own budget without touching another namespace's
804    /// files. `namespace` is validated as a single path token; `Err` if it is unsafe.
805    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    /// Remove in-progress write temporaries left by an earlier process — blob
841    /// files and payload-spool files alike. Files created at or after
842    /// `process_start` belong to the current process and are left untouched.
843    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    /// Create the store directory tree if it is absent.
916    pub fn ensure_created(&self) -> std::io::Result<()> {
917        std::fs::create_dir_all(&self.path)
918    }
919
920    /// Remove the complete store directory tree. Absence is success: the tree
921    /// is already gone.
922    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
1074/// The single-writer store lock: an exclusive advisory lock on
1075/// `<store>/.coven-lock`, held for the life of a full open handle (and its
1076/// running sync loop). A second full open of the same store is refused with
1077/// [`StoreOpenGuardError::AlreadyOpen`] while the lock is held — the invariant
1078/// that keeps two writers from racing the same db and blob store.
1079///
1080/// # Read-only opens take no lock
1081///
1082/// A read-only open deliberately does **not** touch this lock. The lock is
1083/// exclusive, so a shared lock on the same file would block against a writer
1084/// that already holds it (and vice versa) — a reader could never coexist with
1085/// the writer it exists to read alongside. But a read-only open needs no lock
1086/// at all: the lock guards against a second *writer*, and a read-only handle
1087/// holds a `SQLITE_OPEN_READONLY` connection that cannot write. So a read-only
1088/// open skips the guard entirely. Cross-process safety comes from WAL mode (a
1089/// reader sees committed rows while the writer commits more), not from this
1090/// lock; the blob cache a reader may populate is per-device scratch written
1091/// atomically (temp + rename), so a reader and the writer touching the same
1092/// cache file never tear it. This lets one writer and any number of read-only
1093/// readers coexist on one store.
1094pub 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    /// std's `File::try_lock` is an `Unsupported` stub on Android — its cfg
1144    /// list carries `linux` but not `android` — so take the same
1145    /// `flock(LOCK_EX | LOCK_NB)` std takes on Linux, via rustix.
1146    #[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    /// Acquire the guard for a test, panicking on refusal.
1160    #[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/// A temp dir plus a [`StoreDir`] rooted at it. The returned `TempDir` must be
1187/// held for the directory to outlive the test.
1188#[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;