Skip to main content

coven_core/
store_dir.rs

1use std::ops::Deref;
2use std::path::{Path, PathBuf};
3use tracing::info;
4
5use crate::config::{Config, ConfigError};
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 unsigned invite 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
47impl std::fmt::Display for PathTokenError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            PathTokenError::Empty => write!(f, "path token is empty"),
51            PathTokenError::Separator => write!(f, "path token contains a path separator"),
52            PathTokenError::ParentDir => write!(f, "path token contains a parent reference"),
53            PathTokenError::CurDir => write!(f, "path token is a current-directory reference"),
54            PathTokenError::NulByte => write!(f, "path token contains a NUL byte"),
55            PathTokenError::Colon => write!(f, "path token contains a colon"),
56            PathTokenError::Unindexable => {
57                write!(
58                    f,
59                    "id is too short or misaligned to form a partition prefix"
60                )
61            }
62        }
63    }
64}
65
66impl std::error::Error for PathTokenError {}
67
68/// Reject a single untrusted path token (a blob `id`/`namespace`, or a
69/// `store_id`/`sid`) that could escape the directory it is joined onto. A safe
70/// token names exactly one child: no separator, no `..`, no `.`, no NUL, no `:` (a
71/// Windows stream/drive reference), non-empty. The single gate every path builder
72/// and every code decoder runs an untrusted token through, so traversal is refused
73/// before any on-disk or cloud path is formed — and a decoded id is a safe single
74/// component by the time any consumer joins it onto a directory.
75///
76/// Both `.` and `..` are refused: each is a directory-relative reference that a
77/// trailing path component normalizes away, so joining either onto `dir` resolves
78/// to `dir` itself or its parent rather than to a child of `dir`.
79pub fn validate_path_token(token: &str) -> Result<(), PathTokenError> {
80    if token.is_empty() {
81        return Err(PathTokenError::Empty);
82    }
83    if token.contains('\0') {
84        return Err(PathTokenError::NulByte);
85    }
86    if token.contains('/') || token.contains('\\') {
87        return Err(PathTokenError::Separator);
88    }
89    if token.contains(':') {
90        return Err(PathTokenError::Colon);
91    }
92    if token == ".." {
93        return Err(PathTokenError::ParentDir);
94    }
95    if token == "." {
96        return Err(PathTokenError::CurDir);
97    }
98    Ok(())
99}
100
101/// Reject an untrusted `cloud_path` (the consumer's readable object key under the
102/// plain scheme, e.g. `"Artist - Album/cover.jpg"`) that could escape its
103/// namespace prefix in the bucket. Unlike a path token, an interior `/` is
104/// legitimate — the readable path is nested — but every segment still has to be
105/// a canonical path token. Empty, `.`, `..`, colon/platform-prefix, backslash,
106/// and NUL forms are refused before an object key is built. The `cloud_path`
107/// never feeds a local file path, only the cloud object key, so this guards the
108/// keyspace, not the disk.
109pub fn validate_cloud_path(cloud_path: &str) -> Result<(), PathTokenError> {
110    if cloud_path.starts_with('/') {
111        return Err(PathTokenError::Separator);
112    }
113    for segment in cloud_path.split('/') {
114        validate_path_token(segment)?;
115    }
116    Ok(())
117}
118
119/// Default name of the parent directory a store lives under — overridden
120/// per host via [`StoreLayout::stores_dirname`].
121const DEFAULT_STORES_DIRNAME: &str = "stores";
122/// Default name of a store's own database file — overridden per host via
123/// [`StoreLayout::db_filename`].
124const DEFAULT_DB_FILENAME: &str = "store.db";
125
126/// The host's on-disk layout for stores: where they live and what the
127/// database file is called. One rule shared by create, open, join, and
128/// restore, so a host that wants `libraries/<id>/library.db` instead of
129/// coven's default `stores/<id>/store.db` names it once here rather than
130/// each flow hardwiring (or working around) coven's own choice.
131#[derive(Clone, Debug)]
132pub struct StoreLayout {
133    app_dir: PathBuf,
134    stores_dirname: String,
135    db_filename: String,
136}
137
138impl StoreLayout {
139    pub fn new(app_dir: impl Into<PathBuf>) -> Self {
140        Self {
141            app_dir: app_dir.into(),
142            stores_dirname: DEFAULT_STORES_DIRNAME.to_string(),
143            db_filename: DEFAULT_DB_FILENAME.to_string(),
144        }
145    }
146
147    pub fn stores_dirname(mut self, name: impl Into<String>) -> Self {
148        self.stores_dirname = name.into();
149        self
150    }
151
152    pub fn db_filename(mut self, name: impl Into<String>) -> Self {
153        self.db_filename = name.into();
154        self
155    }
156
157    /// The stores parent dir (for host listing/discovery).
158    pub fn stores_root(&self) -> PathBuf {
159        self.app_dir.join(&self.stores_dirname)
160    }
161
162    /// The one `(app_dir, store_id) -> StoreDir` rule, named with this
163    /// layout's directory and database filename. Callers validate an
164    /// untrusted `store_id` ([`validate_path_token`]) BEFORE calling, as
165    /// every join/restore/create flow already does.
166    pub fn store_dir(&self, store_id: &str) -> StoreDir {
167        StoreDir {
168            path: self.stores_root().join(store_id),
169            db_filename: self.db_filename.clone(),
170        }
171    }
172}
173
174/// Typed wrapper for a store directory path.
175///
176/// Centralizes the on-disk layout so callers use methods instead of
177/// ad-hoc `path.join("images")` etc.
178#[derive(Clone, Debug, PartialEq)]
179pub struct StoreDir {
180    path: PathBuf,
181    db_filename: String,
182}
183
184impl StoreDir {
185    pub fn new(path: impl Into<PathBuf>) -> Self {
186        Self {
187            path: path.into(),
188            db_filename: DEFAULT_DB_FILENAME.to_string(),
189        }
190    }
191
192    pub fn db_path(&self) -> PathBuf {
193        self.path.join(&self.db_filename)
194    }
195
196    pub fn config_path(&self) -> PathBuf {
197        self.path.join("config.yaml")
198    }
199
200    /// The two-level partition shard for `id`: `{ab}/{cd}/{id}`, where `{ab}`/`{cd}`
201    /// are the first two byte-pairs of the dash-stripped id. The single home for the
202    /// partition scheme — every blob path (cloud key and on-disk file, hashed or
203    /// pinned/cache) is this shard under some root.
204    ///
205    /// `id` is validated as a single path token and must be long enough (and
206    /// char-boundary aligned) to take the two leading byte-pairs. An id that fails
207    /// is bad data — it could escape the directory or crash the slice — so this
208    /// returns [`PathTokenError`] rather than interpolating it or panicking; the
209    /// caller refuses the blob.
210    pub(crate) fn id_shard(id: &str) -> Result<String, PathTokenError> {
211        validate_path_token(id)?;
212        let hex = id.replace('-', "");
213        if !(hex.is_char_boundary(2) && hex.is_char_boundary(4)) {
214            return Err(PathTokenError::Unindexable);
215        }
216        Ok(format!("{}/{}/{id}", &hex[..2], &hex[2..4]))
217    }
218
219    /// Content-addressed relative path `{prefix}/{ab}/{cd}/{id}`, partitioning by
220    /// the first two byte-pairs of the dash-stripped id. The single home for the
221    /// partition scheme — shared by the local blob store and the cloud layout.
222    ///
223    /// Both `prefix` and `id` are validated as single path tokens, and the id must
224    /// be long enough (and char-boundary aligned) to take the two leading
225    /// byte-pairs the prefix needs. An id that fails is bad data — it could escape
226    /// the directory or crash the slice — so this returns [`PathTokenError`] rather
227    /// than interpolating it or panicking; the caller refuses the blob.
228    pub fn hashed_path(prefix: &str, id: &str) -> Result<String, PathTokenError> {
229        validate_path_token(prefix)?;
230        Ok(format!("{prefix}/{}", Self::id_shard(id)?))
231    }
232
233    /// The cloud object key for a Hashed-scheme blob under the device that
234    /// uploaded it: `{namespace}/{uploader}/{ab}/{cd}/{id}`. The `{uploader}`
235    /// segment is what aligns the blob keyspace to the storage-access rule (a
236    /// member writes only under its own public key), so a bucket ACL can scope each
237    /// member to `{namespace}/{self}/`. Only the *cloud* key carries it; the local
238    /// cache keeps the un-prefixed `{namespace}/{ab}/{cd}/{id}` layout because it is
239    /// per-device. `namespace` and `uploader` are validated as single path tokens;
240    /// the id must be indexable (see [`Self::id_shard`]).
241    pub fn uploader_hashed_key(
242        namespace: &str,
243        uploader: &str,
244        id: &str,
245    ) -> Result<String, PathTokenError> {
246        validate_path_token(namespace)?;
247        validate_path_token(uploader)?;
248        Ok(format!("{namespace}/{uploader}/{}", Self::id_shard(id)?))
249    }
250
251    /// Parse a hashed blob cloud key `{namespace}/{uploader}/{ab}/{cd}/{id}` back
252    /// into `(namespace, uploader, id)`, or `None` when it is not one — a wrong
253    /// segment count, or a shard that does not rebuild (e.g. a plain
254    /// `{namespace}/{cloud_path}` key that happens to have five segments). The
255    /// inverse of [`Self::uploader_hashed_key`], and the single place the layout is
256    /// parsed, shared by the GC, the blob→row lookup, and share authorization.
257    pub fn parse_uploader_hashed_key(cloud_key: &str) -> Option<(String, String, String)> {
258        let mut parts = cloud_key.split('/');
259        let namespace = parts.next()?;
260        let uploader = parts.next()?;
261        let _ab = parts.next()?;
262        let _cd = parts.next()?;
263        let id = parts.next()?;
264        if parts.next().is_some() {
265            return None;
266        }
267        match Self::uploader_hashed_key(namespace, uploader, id) {
268            Ok(rebuilt) if rebuilt == cloud_key => {
269                Some((namespace.to_string(), uploader.to_string(), id.to_string()))
270            }
271            _ => None,
272        }
273    }
274
275    pub fn storage_dir(&self) -> PathBuf {
276        self.path.join("storage")
277    }
278
279    /// Immutable stored bytes prepared for one blob locator. The locator hash is
280    /// the file name, so retries reopen the same exact spool rather than sealing
281    /// the plaintext again with fresh randomness.
282    pub(crate) fn outbound_blob_spool_path(
283        &self,
284        locator_hash: crate::sync::store_commit::ObjectHash,
285    ) -> PathBuf {
286        self.storage_dir()
287            .join("outbound-blobs")
288            .join(locator_hash.to_string())
289    }
290
291    /// A kept (budget-exempt) cache copy of a **Remote** blob:
292    /// `storage/pinned/<namespace>/{ab}/{cd}/<locator-hash>`. The kept sibling of
293    /// [`Self::cache_blob_path`] — same per-namespace shard layout, in the `pinned`
294    /// folder instead of `cache`. The cache's truth is the folder a blob's file lives
295    /// in, not a table; a file here is a Remote blob's cache copy the user pinned for
296    /// offline (kept from eviction). `Err` if `namespace` is unsafe.
297    pub fn pinned_blob_path(
298        &self,
299        namespace: &str,
300        locator_hash: crate::sync::store_commit::ObjectHash,
301    ) -> Result<PathBuf, PathTokenError> {
302        self.cache_folder_blob_path("pinned", namespace, &locator_hash.to_string())
303    }
304
305    /// An opportunistic (evictable) cache copy of a **Remote** blob:
306    /// `storage/cache/<namespace>/{ab}/{cd}/<locator-hash>`. A file here is a cached-but-unpinned
307    /// blob — fetched on read or eagerly on pull, droppable by the budget sweep. The
308    /// folder it lives in, not a table, is what makes it evictable rather than kept.
309    /// Segmented by `namespace` so each namespace's budget evicts only its own
310    /// subtree (see [`Self::cache_namespace_dir`]). `Err` if
311    /// `namespace` is unsafe.
312    pub fn cache_blob_path(
313        &self,
314        namespace: &str,
315        locator_hash: crate::sync::store_commit::ObjectHash,
316    ) -> Result<PathBuf, PathTokenError> {
317        self.cache_folder_blob_path("cache", namespace, &locator_hash.to_string())
318    }
319
320    /// `storage/<folder>/<namespace>/{ab}/{cd}/<locator-hash>` — the single blob-path builder
321    /// behind [`Self::cache_blob_path`] (`folder` = `cache`) and
322    /// [`Self::pinned_blob_path`] (`folder` = `pinned`), which differ only by the
323    /// folder token. Composes the per-namespace dir
324    /// ([`Self::cache_folder_namespace_dir`]) with the locator-hash shard, so the layout lives
325    /// in one place. `namespace` and the locator hash are validated.
326    fn cache_folder_blob_path(
327        &self,
328        folder: &str,
329        namespace: &str,
330        id: &str,
331    ) -> Result<PathBuf, PathTokenError> {
332        Ok(self
333            .cache_folder_namespace_dir(folder, namespace)?
334            .join(Self::id_shard(id)?))
335    }
336
337    /// `storage/<folder>/<namespace>` for a cache folder (`cache` evictable / `pinned`
338    /// kept), `namespace` validated as a single path token. The per-namespace dir both
339    /// cache folders compose onto; [`Self::cache_namespace_dir`] is the evictable case
340    /// the budget sweep walks.
341    fn cache_folder_namespace_dir(
342        &self,
343        folder: &str,
344        namespace: &str,
345    ) -> Result<PathBuf, PathTokenError> {
346        validate_path_token(namespace)?;
347        Ok(self.storage_dir().join(folder).join(namespace))
348    }
349
350    /// coven's own copy of a **host-provided Local** blob:
351    /// `storage/local/<namespace>/<id>`. This is NOT a cache copy — it is the blob's
352    /// home while its release is Local (a host-provided blob has no user path). It
353    /// is never evicted: the budget sweep walks only [`Self::cache_dir`], never
354    /// `storage/local`. Both `namespace` and `id` are validated as single path
355    /// tokens (the blob columns come from a row any write-capable member authored),
356    /// so neither can escape the store. `Err` if either is unsafe.
357    pub fn local_blob_path(&self, namespace: &str, id: &str) -> Result<PathBuf, PathTokenError> {
358        validate_path_token(namespace)?;
359        validate_path_token(id)?;
360        Ok(self
361            .path
362            .join("storage")
363            .join("local")
364            .join(namespace)
365            .join(id))
366    }
367
368    /// The evictable-cache root, `storage/cache`, holding every namespace's subtree.
369    /// The per-namespace budget sweep walks only one namespace's subtree under it —
370    /// see [`Self::cache_namespace_dir`].
371    pub fn cache_dir(&self) -> PathBuf {
372        self.storage_dir().join("cache")
373    }
374
375    /// One namespace's evictable-cache subtree, `storage/cache/<namespace>`. The
376    /// budget sweep ([`crate::blob::cache::evict_to_budget`]) walks only this tree, so
377    /// a namespace evicts against its own budget without touching another namespace's
378    /// files. `namespace` is validated as a single path token; `Err` if it is unsafe.
379    pub fn cache_namespace_dir(&self, namespace: &str) -> Result<PathBuf, PathTokenError> {
380        self.cache_folder_namespace_dir("cache", namespace)
381    }
382
383    /// Create a store directory, generate a device_id, and save config.yaml.
384    ///
385    /// The caller is responsible for encryption key setup and calling
386    /// `Config::save_active_store()` afterward.
387    ///
388    /// `store_id` is device-generated here (trusted), but it still has to name a
389    /// single directory under `layout`'s stores dir, so it passes the same
390    /// [`validate_path_token`] gate the untrusted join/restore ids pass — a
391    /// malformed id fails loudly rather than forming a stray path.
392    ///
393    /// Refuses with [`ConfigError::StoreExists`] if the directory already
394    /// exists — the same refusal join and restore give their own `stores/<id>`,
395    /// so a `store_id` collision (e.g. a device-generated id reused, or two
396    /// concurrent creates) fails loudly here too rather than overwriting an
397    /// existing store's directory.
398    pub fn create(
399        layout: &StoreLayout,
400        store_id: String,
401        store_name: String,
402        ids: &dyn crate::id_provider::IdProvider,
403    ) -> Result<Config, ConfigError> {
404        validate_path_token(&store_id)
405            .map_err(|e| ConfigError::Config(format!("invalid store id: {e}")))?;
406        let store_dir = layout.store_dir(&store_id);
407        if store_dir.exists() {
408            return Err(ConfigError::StoreExists(store_id));
409        }
410        std::fs::create_dir_all(&*store_dir)?;
411
412        let device_id = ids.new_id();
413        let config = Config::with_defaults(store_id, device_id, store_dir, store_name);
414        config.save_to_config_yaml()?;
415
416        info!("Created store at {}", config.store_dir.display());
417        Ok(config)
418    }
419}
420
421impl Deref for StoreDir {
422    type Target = Path;
423
424    fn deref(&self) -> &Path {
425        &self.path
426    }
427}
428
429impl AsRef<Path> for StoreDir {
430    fn as_ref(&self) -> &Path {
431        &self.path
432    }
433}
434
435impl From<PathBuf> for StoreDir {
436    fn from(path: PathBuf) -> Self {
437        Self {
438            path,
439            db_filename: DEFAULT_DB_FILENAME.to_string(),
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    /// A normal id partitions by its first two dash-stripped byte-pairs, with the
449    /// full id (dashes kept) as the file name — the layout the cloud and local
450    /// stores share.
451    #[test]
452    fn hashed_path_partitions_a_normal_id() {
453        let id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
454        assert_eq!(
455            StoreDir::hashed_path("images", id).expect("valid id"),
456            format!("images/a1/b2/{id}"),
457        );
458    }
459
460    /// An id too short to take the `{ab}/{cd}` prefix cannot index a two-byte
461    /// shard, so it is rejected as `Unindexable` rather than slicing past its end.
462    #[test]
463    fn hashed_path_refuses_a_short_id_instead_of_panicking() {
464        assert_eq!(
465            StoreDir::hashed_path("images", "a"),
466            Err(PathTokenError::Unindexable),
467        );
468    }
469
470    /// An id whose dash-stripped form splits a multi-byte char at the prefix
471    /// boundary is unindexable too, not a panic.
472    #[test]
473    fn hashed_path_refuses_a_misaligned_multibyte_id() {
474        // 'é' is two bytes; "aé" puts a char boundary failure at byte 2.
475        assert_eq!(
476            StoreDir::hashed_path("images", "aé"),
477            Err(PathTokenError::Unindexable),
478        );
479    }
480
481    /// An id or namespace carrying a separator, a `..`, or a NUL is a traversal
482    /// attempt and is refused before any path is built.
483    #[test]
484    fn hashed_path_refuses_traversal_tokens() {
485        assert_eq!(
486            StoreDir::hashed_path("images", "ab/../../etc/passwd"),
487            Err(PathTokenError::Separator),
488        );
489        assert_eq!(
490            StoreDir::hashed_path("images", ".."),
491            Err(PathTokenError::ParentDir),
492        );
493        assert_eq!(
494            StoreDir::hashed_path("images", "a\0b"),
495            Err(PathTokenError::NulByte),
496        );
497        assert_eq!(
498            StoreDir::hashed_path("im/ages", "abcd"),
499            Err(PathTokenError::Separator),
500        );
501    }
502
503    /// `validate_path_token` accepts an ordinary single token and rejects each
504    /// escape shape: separators (`/` and `\`), a leading slash (an absolute path),
505    /// a bare `..` and a bare `.` (both directory-relative references that
506    /// normalize away to land off a child), a NUL, a `:` (a Windows
507    /// alternate-data-stream / drive-relative reference), and the empty string.
508    #[test]
509    fn validate_path_token_accepts_safe_and_rejects_escapes() {
510        assert_eq!(validate_path_token("abc123"), Ok(()));
511        assert_eq!(validate_path_token(""), Err(PathTokenError::Empty));
512        assert_eq!(validate_path_token("a/b"), Err(PathTokenError::Separator));
513        assert_eq!(validate_path_token("a\\b"), Err(PathTokenError::Separator));
514        assert_eq!(validate_path_token("/abs"), Err(PathTokenError::Separator));
515        assert_eq!(validate_path_token(".."), Err(PathTokenError::ParentDir));
516        assert_eq!(validate_path_token("."), Err(PathTokenError::CurDir));
517        assert_eq!(validate_path_token("a\0b"), Err(PathTokenError::NulByte));
518        assert_eq!(validate_path_token("foo:bar"), Err(PathTokenError::Colon));
519        assert_eq!(validate_path_token("c:"), Err(PathTokenError::Colon));
520    }
521
522    /// A lone `.` is rejected just as `..` is: a trailing `.` component is
523    /// normalized away when the path resolves, so `stores/.` would land on
524    /// `stores`'s parent (the data dir) rather than name a child of `stores/`
525    /// — an escape. The unit under test is the rejection itself.
526    #[test]
527    fn validate_path_token_rejects_lone_current_dir() {
528        assert_eq!(validate_path_token("."), Err(PathTokenError::CurDir));
529    }
530
531    /// A `cloud_path` is a readable object key, so an interior `/` is legitimate
532    /// (nested path), but every component must itself be a canonical path token.
533    #[test]
534    fn validate_cloud_path_allows_nesting_but_rejects_escapes() {
535        assert_eq!(validate_cloud_path("Artist - Album/cover.jpg"), Ok(()));
536        assert_eq!(validate_cloud_path(""), Err(PathTokenError::Empty));
537        assert_eq!(
538            validate_cloud_path("../escape"),
539            Err(PathTokenError::ParentDir),
540        );
541        assert_eq!(
542            validate_cloud_path("a/../../escape"),
543            Err(PathTokenError::ParentDir),
544        );
545        assert_eq!(validate_cloud_path("/abs"), Err(PathTokenError::Separator));
546        assert_eq!(validate_cloud_path("a\\b"), Err(PathTokenError::Separator),);
547        assert_eq!(validate_cloud_path("a/./b"), Err(PathTokenError::CurDir));
548        assert_eq!(validate_cloud_path("a//b"), Err(PathTokenError::Empty));
549        assert_eq!(validate_cloud_path("a/b/"), Err(PathTokenError::Empty));
550        assert_eq!(validate_cloud_path("C:/b"), Err(PathTokenError::Colon));
551        assert_eq!(validate_cloud_path("a/C:b"), Err(PathTokenError::Colon));
552        assert_eq!(validate_cloud_path("a/b\0c"), Err(PathTokenError::NulByte));
553    }
554
555    #[test]
556    fn outbound_blob_spool_is_keyed_by_locator_hash() {
557        let store = StoreDir::new("/stores/example");
558        let locator_hash = crate::sync::store_commit::ObjectHash::digest(b"locator");
559
560        assert_eq!(
561            store.outbound_blob_spool_path(locator_hash),
562            store
563                .storage_dir()
564                .join("outbound-blobs")
565                .join(locator_hash.to_string())
566        );
567    }
568
569    #[test]
570    fn remote_cache_paths_are_keyed_by_locator_hash() {
571        let store = StoreDir::new("/stores/example");
572        let locator_hash = crate::sync::store_commit::ObjectHash::digest(b"locator");
573        let shard = StoreDir::id_shard(&locator_hash.to_string()).expect("hash shard");
574
575        assert_eq!(
576            store
577                .cache_blob_path("images", locator_hash)
578                .expect("cache path"),
579            store.storage_dir().join("cache/images").join(&shard)
580        );
581        assert_eq!(
582            store
583                .pinned_blob_path("images", locator_hash)
584                .expect("pinned path"),
585            store.storage_dir().join("pinned/images").join(shard)
586        );
587    }
588}