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}