Skip to main content

loonfs_objectstore/
local_fs_store.rs

1//! [`LocalFsStore`]: the local-filesystem [`ObjectStore`] provider.
2//!
3//! This is the dev and test provider. On Unix-family platforms, sibling
4//! staging files and atomic rename-replace give it the same replacement
5//! visibility contract as the cloud providers. Construction fails on other
6//! platforms rather than exposing a weaker contract. Its performance shapes
7//! are deliberately relaxed (content-hash etags, whole-file reads,
8//! whole-tree listings) and are not optimization targets.
9
10use crate::keyspace::{
11    normalize_key_prefix, scope_list_prefix, scope_object_key, unscope_listed_key,
12    validate_segments,
13};
14use crate::object_store::Result;
15use crate::{
16    ByteRange, ByteStream, ObjectBody, ObjectMetadata, ObjectStore, ObjectStoreError, PutMode,
17    StoredObjectChecksum,
18};
19use async_trait::async_trait;
20use bytes::Bytes;
21use futures::stream::{self, BoxStream, StreamExt};
22use loonfs_api::{sha256_digest, StorageChecksum};
23use std::io::SeekFrom;
24use std::path::{Component, Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26use tokio::fs::{self, File, OpenOptions};
27use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
28use tokio::sync::Mutex;
29
30/// Lock file serializing mutations across processes sharing one store root.
31/// Held only for the duration of each write; the OS releases it if the
32/// holding process dies. Never listed as an object (see `is_scratch_name`).
33const STORE_LOCK_FILE_NAME: &str = ".loonfs-store.lock";
34
35/// Implements the object-store contract on a Unix-family local directory.
36///
37/// Replacements are atomic: a concurrent reader observes either the complete
38/// prior object or the complete replacement, never a missing or partial
39/// object.
40#[derive(Debug)]
41pub struct LocalFsStore {
42    root: PathBuf,
43    /// Logical prefix every key is confined beneath, or `None` for the root.
44    key_prefix: Option<String>,
45    write_lock: Mutex<()>,
46}
47
48impl LocalFsStore {
49    /// Opens a local store, creating its root directory when necessary.
50    ///
51    /// Construction fails outside Unix-family platforms, where the provider
52    /// does not claim atomic rename-replace support, or when the root cannot
53    /// be created.
54    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
55        Self::with_key_prefix(root, None)
56    }
57
58    /// Opens a local store whose keys are confined beneath `key_prefix`,
59    /// matching how the provider adapters scope theirs.
60    pub fn with_key_prefix(root: impl Into<PathBuf>, key_prefix: Option<&str>) -> Result<Self> {
61        require_atomic_rename_replace()?;
62        let root = root.into();
63        std::fs::create_dir_all(&root).map_err(|err| {
64            ObjectStoreError::Configuration(format!(
65                "failed to create store root `{}`: {err}",
66                root.display()
67            ))
68        })?;
69        Ok(Self {
70            root,
71            key_prefix: normalize_key_prefix(key_prefix)?,
72            write_lock: Mutex::new(()),
73        })
74    }
75
76    /// Extends the in-process write mutex across processes with an advisory
77    /// file lock on the store root. Check-then-act writes (compare-and-swap,
78    /// delete-then-prune) are only safe while both are held.
79    async fn acquire_cross_process_write_lock(&self, key: &str) -> Result<std::fs::File> {
80        let lock_path = self.root.join(STORE_LOCK_FILE_NAME);
81        let lock_key = key.to_owned();
82        tokio::task::spawn_blocking(move || {
83            let file = std::fs::OpenOptions::new()
84                .read(true)
85                .write(true)
86                .create(true)
87                .truncate(false)
88                .open(&lock_path)
89                .map_err(|err| io_error(&lock_key, err))?;
90            fs4::fs_std::FileExt::lock_exclusive(&file).map_err(|err| io_error(&lock_key, err))?;
91            Ok(file)
92        })
93        .await
94        .map_err(|err| ObjectStoreError::transport(key, format!("store lock task failed: {err}")))?
95    }
96
97    /// Returns the filesystem directory beneath which validated object keys are resolved.
98    pub fn root(&self) -> &Path {
99        &self.root
100    }
101
102    fn resolve_key(&self, key: &str) -> Result<PathBuf> {
103        let segments = validate_segments(key, false)?;
104        // Scratch-shaped names are reserved by this store: they are hidden
105        // from listings, so accepting them as keys would create objects a
106        // listing can never report.
107        if segments.iter().any(|segment| is_scratch_name(segment)) {
108            return Err(ObjectStoreError::InvalidKey {
109                object_key: key.to_owned(),
110                message: "key uses a segment name reserved for store scratch files".to_owned(),
111            });
112        }
113        let mut path = self.root.clone();
114        for segment in segments {
115            path.push(segment);
116        }
117        Ok(path)
118    }
119
120    async fn metadata_for_path(key: &str, path: &Path) -> Result<Option<ObjectMetadata>> {
121        let metadata = match fs::metadata(path).await {
122            Ok(metadata) => metadata,
123            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
124            Err(err) => return Err(io_error(key, err)),
125        };
126        let Some(content_bytes) = Self::read_for_digest(key, path).await? else {
127            return Ok(None);
128        };
129        let content_digest = sha256_digest(&content_bytes);
130        Self::metadata_from_fs_metadata(key, &metadata, &content_digest, path).map(Some)
131    }
132
133    /// Reads the object's bytes for the digest, answering `None` when the
134    /// object is gone. The stat above and this read are separate syscalls,
135    /// so a concurrent delete can land between them; a head that races a
136    /// delete reports "gone" — the answer a provider's head gives — never
137    /// a transport error.
138    async fn read_for_digest(key: &str, path: &Path) -> Result<Option<Vec<u8>>> {
139        match fs::read(path).await {
140            Ok(bytes) => Ok(Some(bytes)),
141            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
142            Err(err) => Err(io_error(key, err)),
143        }
144    }
145
146    fn metadata_from_fs_metadata(
147        key: &str,
148        metadata: &std::fs::Metadata,
149        content_digest: &str,
150        path: &Path,
151    ) -> Result<ObjectMetadata> {
152        if !metadata.is_file() {
153            return Err(ObjectStoreError::transport(
154                key,
155                format!("object path is not a file: {}", path.display()),
156            ));
157        }
158
159        let last_modified_ms = metadata
160            .modified()
161            .ok()
162            .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
163            .and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok());
164        Ok(ObjectMetadata {
165            etag: Some(format!("local-fs-v1:{content_digest}")),
166            version: None,
167            size_bytes: metadata.len(),
168            last_modified_ms,
169        })
170    }
171
172    async fn create_new_object(key: &str, root: &Path, path: &Path, bytes: &[u8]) -> Result<()> {
173        // Stage to a temp file and link into place, like `replace_object`:
174        // the bytes become visible at the final key atomically, so a reader
175        // never observes a partial object and a crash never leaves a torn
176        // file wedging the key. `hard_link` fails if the key exists, which
177        // is exactly the create-if-absent precondition.
178        let created_dirs = ensure_parent_dir(key, path).await?;
179        let temp_path = temp_path(path);
180        let mut file = OpenOptions::new()
181            .write(true)
182            .create_new(true)
183            .open(&temp_path)
184            .await
185            .map_err(|err| io_error(key, err))?;
186
187        let staged: Result<()> = async {
188            file.write_all(bytes)
189                .await
190                .map_err(|err| io_error(key, err))?;
191            file.sync_all().await.map_err(|err| io_error(key, err))
192        }
193        .await;
194        if staged.is_err() {
195            let _ = fs::remove_file(&temp_path).await;
196            return staged;
197        }
198
199        let linked = fs::hard_link(&temp_path, path)
200            .await
201            .map_err(|err| map_create_error(key, err));
202        let _ = fs::remove_file(&temp_path).await;
203        linked?;
204
205        if created_dirs {
206            sync_dir_chain(key, path, root).await?;
207        }
208        sync_parent_dir(key, path).await
209    }
210
211    /// Writes a streamed payload to a sibling staging file, then links or
212    /// renames it into place exactly as the buffered writes do.
213    ///
214    /// Nothing but the current chunk is ever held: the staging file is the
215    /// buffer. The payload is fully written and fsynced before the mode's
216    /// precondition is evaluated, which is the contract a caller folding a
217    /// digest over the same stream depends on.
218    async fn put_streamed_object(
219        &self,
220        key: &str,
221        mut body: ByteStream,
222        mode: PutMode,
223    ) -> Result<u64> {
224        let path = self.resolve_key(key)?;
225        let created_dirs = ensure_parent_dir(key, &path).await?;
226        let temp_path = temp_path(&path);
227        let mut file = OpenOptions::new()
228            .write(true)
229            .create_new(true)
230            .open(&temp_path)
231            .await
232            .map_err(|err| io_error(key, err))?;
233
234        let staged: Result<u64> = async {
235            let mut size_bytes = 0u64;
236            while let Some(chunk) = body.next().await {
237                let chunk = chunk?;
238                file.write_all(&chunk)
239                    .await
240                    .map_err(|err| io_error(key, err))?;
241                size_bytes += chunk.len() as u64;
242            }
243            file.sync_all().await.map_err(|err| io_error(key, err))?;
244            Ok(size_bytes)
245        }
246        .await;
247        let size_bytes = match staged {
248            Ok(size_bytes) => size_bytes,
249            Err(err) => {
250                let _ = fs::remove_file(&temp_path).await;
251                return Err(err);
252            }
253        };
254
255        let published = self
256            .publish_staged_object(key, &path, &temp_path, mode)
257            .await;
258        let _ = fs::remove_file(&temp_path).await;
259        published?;
260
261        if created_dirs {
262            sync_dir_chain(key, &path, &self.root).await?;
263        }
264        sync_parent_dir(key, &path).await?;
265        Ok(size_bytes)
266    }
267
268    /// Moves a fully written staging file to its key under `mode`'s
269    /// precondition, holding both write locks while it checks and acts.
270    async fn publish_staged_object(
271        &self,
272        key: &str,
273        path: &Path,
274        temp_path: &Path,
275        mode: PutMode,
276    ) -> Result<()> {
277        let _guard = self.write_lock.lock().await;
278        let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
279        let precondition_failed = || ObjectStoreError::PreconditionFailed {
280            object_key: key.to_owned(),
281        };
282        match mode {
283            PutMode::Overwrite => fs::rename(temp_path, path)
284                .await
285                .map_err(|err| io_error(key, err)),
286            // `hard_link` fails if the key exists, which is exactly the
287            // create-if-absent precondition.
288            PutMode::CreateIfAbsent => fs::hard_link(temp_path, path)
289                .await
290                .map_err(|err| map_create_error(key, err)),
291            PutMode::CompareAndSwap { expected_etag } => {
292                let current = Self::metadata_for_path(key, path)
293                    .await?
294                    .ok_or_else(precondition_failed)?;
295                if current.etag.as_deref() != Some(expected_etag.as_str()) {
296                    return Err(precondition_failed());
297                }
298                fs::rename(temp_path, path)
299                    .await
300                    .map_err(|err| io_error(key, err))
301            }
302        }
303    }
304
305    async fn replace_object(key: &str, root: &Path, path: &Path, bytes: &[u8]) -> Result<()> {
306        let created_dirs = ensure_parent_dir(key, path).await?;
307        let temp_path = temp_path(path);
308
309        let result: Result<()> = async {
310            let mut file = OpenOptions::new()
311                .write(true)
312                .create_new(true)
313                .open(&temp_path)
314                .await
315                .map_err(|err| io_error(key, err))?;
316            file.write_all(bytes)
317                .await
318                .map_err(|err| io_error(key, err))?;
319            file.sync_all().await.map_err(|err| io_error(key, err))?;
320
321            fs::rename(&temp_path, path)
322                .await
323                .map_err(|err| io_error(key, err))
324        }
325        .await;
326
327        if result.is_err() {
328            let _ = fs::remove_file(&temp_path).await;
329            return result;
330        }
331
332        if created_dirs {
333            sync_dir_chain(key, path, root).await?;
334        }
335        sync_parent_dir(key, path).await
336    }
337}
338
339impl LocalFsStore {
340    async fn head_object(&self, key: &str) -> Result<Option<ObjectMetadata>> {
341        let path = self.resolve_key(key)?;
342        Self::metadata_for_path(key, &path).await
343    }
344
345    async fn get_with_metadata_object(&self, key: &str) -> Result<Option<ObjectBody>> {
346        let path = self.resolve_key(key)?;
347        let mut file = match File::open(&path).await {
348            Ok(file) => file,
349            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
350            Err(err) => return Err(io_error(key, err)),
351        };
352        let fs_metadata = file.metadata().await.map_err(|err| io_error(key, err))?;
353
354        let mut bytes = Vec::new();
355        file.read_to_end(&mut bytes)
356            .await
357            .map_err(|err| io_error(key, err))?;
358
359        let content_digest = sha256_digest(&bytes);
360        let metadata = Self::metadata_from_fs_metadata(key, &fs_metadata, &content_digest, &path)?;
361        Ok(Some(ObjectBody { metadata, bytes }))
362    }
363
364    /// Reads the whole object, or exactly one range of it.
365    ///
366    /// A ranged read seeks to its start and reads only its length, so it
367    /// holds the range and never the object. That is what a caller reading a
368    /// large object in bounded chunks is promised, and a store that
369    /// materialized the whole object to answer each chunk would break the
370    /// promise on this provider alone.
371    async fn get_object(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Vec<u8>>> {
372        let path = self.resolve_key(key)?;
373        let mut file = match File::open(&path).await {
374            Ok(file) => file,
375            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
376            Err(err) => return Err(io_error(key, err)),
377        };
378
379        let Some(range) = range else {
380            let mut bytes = Vec::new();
381            file.read_to_end(&mut bytes)
382                .await
383                .map_err(|err| io_error(key, err))?;
384            return Ok(Some(bytes));
385        };
386
387        let invalid_range = || ObjectStoreError::InvalidRange {
388            object_key: key.to_owned(),
389        };
390        let size_bytes = file
391            .metadata()
392            .await
393            .map_err(|err| io_error(key, err))?
394            .len();
395        if range.end_exclusive < range.start_inclusive || range.start_inclusive > size_bytes {
396            return Err(invalid_range());
397        }
398        // A range ending past the object is truncated, never refused.
399        let end_exclusive = range.end_exclusive.min(size_bytes);
400        file.seek(SeekFrom::Start(range.start_inclusive))
401            .await
402            .map_err(|err| io_error(key, err))?;
403        let mut bytes = Vec::new();
404        file.take(end_exclusive - range.start_inclusive)
405            .read_to_end(&mut bytes)
406            .await
407            .map_err(|err| io_error(key, err))?;
408        Ok(Some(bytes))
409    }
410
411    async fn put_object(&self, key: &str, bytes: &[u8], mode: PutMode) -> Result<ObjectMetadata> {
412        let path = self.resolve_key(key)?;
413        let _guard = self.write_lock.lock().await;
414        let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
415        let precondition_failed = || ObjectStoreError::PreconditionFailed {
416            object_key: key.to_owned(),
417        };
418
419        match mode {
420            PutMode::Overwrite => Self::replace_object(key, &self.root, &path, bytes).await?,
421            PutMode::CreateIfAbsent => {
422                if fs::try_exists(&path)
423                    .await
424                    .map_err(|err| io_error(key, err))?
425                {
426                    return Err(precondition_failed());
427                }
428                Self::create_new_object(key, &self.root, &path, bytes).await?;
429            }
430            PutMode::CompareAndSwap { expected_etag } => {
431                let current = Self::metadata_for_path(key, &path)
432                    .await?
433                    .ok_or_else(precondition_failed)?;
434                if current.etag.as_deref() != Some(expected_etag.as_str()) {
435                    return Err(precondition_failed());
436                }
437                Self::replace_object(key, &self.root, &path, bytes).await?;
438            }
439        }
440
441        Self::metadata_for_path(key, &path)
442            .await?
443            .ok_or_else(|| ObjectStoreError::transport(key, "object disappeared after write"))
444    }
445
446    async fn delete_object(&self, key: &str) -> Result<()> {
447        let path = self.resolve_key(key)?;
448        let _guard = self.write_lock.lock().await;
449        let _cross_process_guard = self.acquire_cross_process_write_lock(key).await?;
450
451        match fs::remove_file(&path).await {
452            Ok(()) => {
453                sync_parent_dir(key, &path).await?;
454                prune_empty_parent_dirs(key, path.parent(), &self.root).await?;
455                Ok(())
456            }
457            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
458            Err(err) => Err(io_error(key, err)),
459        }
460    }
461}
462
463impl LocalFsStore {
464    /// Confines one caller key beneath the configured prefix.
465    fn scoped(&self, key: &str) -> Result<String> {
466        scope_object_key(self.key_prefix.as_deref(), key)
467    }
468}
469
470#[async_trait]
471impl ObjectStore for LocalFsStore {
472    async fn head(&self, key: &str) -> Result<Option<ObjectMetadata>> {
473        self.head_object(&self.scoped(key)?).await
474    }
475
476    /// The reference provider stores no checksum beside an object, so it
477    /// computes one from the object it holds. That is the same guarantee a
478    /// cloud provider's stored checksum gives — the provider attesting to
479    /// the bytes it actually has — and it never crosses a network.
480    async fn head_stored_checksum(&self, key: &str) -> Result<Option<StoredObjectChecksum>> {
481        let scoped = self.scoped(key)?;
482        let Some(bytes) = self.get_object(&scoped, None).await? else {
483            return Ok(None);
484        };
485        Ok(Some(StoredObjectChecksum {
486            size_bytes: bytes.len() as u64,
487            storage_checksum: StorageChecksum::sha256(&bytes),
488        }))
489    }
490
491    async fn get_with_metadata(&self, key: &str) -> Result<Option<ObjectBody>> {
492        self.get_with_metadata_object(&self.scoped(key)?).await
493    }
494
495    async fn get(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Bytes>> {
496        self.get_object(&self.scoped(key)?, range)
497            .await
498            .map(|maybe| maybe.map(Bytes::from))
499    }
500
501    async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> Result<ObjectMetadata> {
502        self.put_object(&self.scoped(key)?, &bytes, mode).await
503    }
504
505    async fn put_streamed(&self, key: &str, body: ByteStream, mode: PutMode) -> Result<u64> {
506        self.put_streamed_object(&self.scoped(key)?, body, mode)
507            .await
508    }
509
510    async fn delete(&self, key: &str) -> Result<()> {
511        self.delete_object(&self.scoped(key)?).await
512    }
513
514    fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, Result<String>> {
515        let scoped = match scope_list_prefix(self.key_prefix.as_deref(), prefix) {
516            Ok(scoped) => scoped,
517            Err(err) => return stream::once(async { Err(err) }).boxed(),
518        };
519        let root = self.root.clone();
520        let key_prefix = self.key_prefix.clone();
521        Box::pin(
522            stream::once(async move { list_prefix_for_root(root, scoped).await })
523                .flat_map(|result| match result {
524                    Ok(keys) => stream::iter(keys.into_iter().map(Ok)).boxed(),
525                    Err(err) => stream::once(async { Err(err) }).boxed(),
526                })
527                .filter_map(move |result| {
528                    let key_prefix = key_prefix.clone();
529                    async move {
530                        match result {
531                            Ok(key) => match key_prefix.as_deref() {
532                                Some(prefix) => unscope_listed_key(Some(prefix), &key).map(Ok),
533                                None => Some(Ok(key)),
534                            },
535                            Err(err) => Some(Err(err)),
536                        }
537                    }
538                }),
539        )
540    }
541}
542
543#[cfg(unix)]
544fn require_atomic_rename_replace() -> Result<()> {
545    Ok(())
546}
547
548#[cfg(not(unix))]
549fn require_atomic_rename_replace() -> Result<()> {
550    Err(ObjectStoreError::Configuration(
551        "local filesystem provider requires atomic rename-replace and is supported only on \
552         Unix-family platforms"
553            .to_owned(),
554    ))
555}
556
557async fn list_prefix_for_root(root: PathBuf, prefix: String) -> Result<Vec<String>> {
558    validate_segments(&prefix, true)?;
559
560    if !fs::try_exists(&root)
561        .await
562        .map_err(|err| io_error(&prefix, err))?
563    {
564        return Ok(Vec::new());
565    }
566
567    let mut keys = collect_keys(&prefix, root).await?;
568    keys.retain(|key| key.starts_with(&prefix));
569    keys.sort();
570    Ok(keys)
571}
572
573/// Creates the parent directory chain. Returns whether anything was created,
574/// so callers know the new directory entries also need to be made durable.
575async fn ensure_parent_dir(key: &str, path: &Path) -> Result<bool> {
576    match path.parent() {
577        Some(parent) => {
578            if fs::try_exists(parent)
579                .await
580                .map_err(|err| io_error(key, err))?
581            {
582                return Ok(false);
583            }
584            fs::create_dir_all(parent)
585                .await
586                .map_err(|err| io_error(key, err))?;
587            Ok(true)
588        }
589        None => Err(ObjectStoreError::InvalidKey {
590            object_key: key.to_owned(),
591            message: format!("object path `{}` has no parent directory", path.display()),
592        }),
593    }
594}
595
596/// Fsyncs the directory holding `path`, making a rename, create, or unlink
597/// of that entry durable. Without this, the file data can survive a crash
598/// while the directory entry pointing at it does not.
599async fn sync_parent_dir(key: &str, path: &Path) -> Result<()> {
600    #[cfg(unix)]
601    {
602        if let Some(parent) = path.parent() {
603            let dir = File::open(parent).await.map_err(|err| io_error(key, err))?;
604            dir.sync_all().await.map_err(|err| io_error(key, err))?;
605        }
606    }
607    #[cfg(not(unix))]
608    {
609        let _ = (key, path);
610    }
611    Ok(())
612}
613
614/// Fsyncs every directory from `path`'s parent up to and including the
615/// store root, so a freshly created directory chain survives a crash. The
616/// root itself pre-exists, so the chain never needs to go past it.
617async fn sync_dir_chain(key: &str, path: &Path, root: &Path) -> Result<()> {
618    #[cfg(unix)]
619    {
620        let mut current = path.parent();
621        while let Some(dir) = current {
622            let handle = File::open(dir).await.map_err(|err| io_error(key, err))?;
623            handle.sync_all().await.map_err(|err| io_error(key, err))?;
624            if dir == root {
625                break;
626            }
627            current = dir.parent();
628        }
629    }
630    #[cfg(not(unix))]
631    {
632        let _ = (key, path, root);
633    }
634    Ok(())
635}
636
637async fn collect_keys(prefix: &str, root: PathBuf) -> Result<Vec<String>> {
638    let mut keys = Vec::new();
639    let mut dirs = vec![root.clone()];
640
641    while let Some(current) = dirs.pop() {
642        // Concurrent writers race this walk by design: deletes prune empty
643        // parent directories and publishes write-then-rename scratch files.
644        // An entry that vanishes between enumeration and inspection is an
645        // absent key, not a listing failure — exactly what a cloud
646        // provider's list reports for it.
647        let mut reader = match fs::read_dir(&current).await {
648            Ok(reader) => reader,
649            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
650            Err(err) => return Err(io_error(prefix, err)),
651        };
652        let mut entries = Vec::new();
653        loop {
654            match reader.next_entry().await {
655                Ok(Some(entry)) => entries.push(entry.path()),
656                Ok(None) => break,
657                Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
658                Err(err) => return Err(io_error(prefix, err)),
659            }
660        }
661        entries.sort();
662
663        for path in entries.into_iter().rev() {
664            // Filter in-flight scratch names before the stat: a scratch
665            // file is renamed away mid-publish, so inspecting it first
666            // would turn the normal rename race into a listing error.
667            if path
668                .file_name()
669                .and_then(|name| name.to_str())
670                .is_some_and(is_scratch_name)
671            {
672                continue;
673            }
674            let metadata = match fs::metadata(&path).await {
675                Ok(metadata) => metadata,
676                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
677                Err(err) => return Err(io_error(prefix, err)),
678            };
679            if metadata.is_dir() {
680                dirs.push(path);
681                continue;
682            }
683
684            if metadata.is_file() {
685                keys.push(relative_key(prefix, &root, &path)?);
686            }
687        }
688    }
689
690    keys.sort();
691    Ok(keys)
692}
693
694fn relative_key(prefix: &str, root: &Path, path: &Path) -> Result<String> {
695    let relative = path.strip_prefix(root).map_err(|err| {
696        ObjectStoreError::transport(
697            prefix,
698            format!(
699                "failed to strip object-store root from path {}: {err}",
700                path.display()
701            ),
702        )
703    })?;
704
705    let mut parts = Vec::new();
706    for component in relative.components() {
707        match component {
708            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
709            other => {
710                return Err(ObjectStoreError::transport(
711                    prefix,
712                    format!(
713                        "unsupported relative path component {other:?} under local object store"
714                    ),
715                ))
716            }
717        }
718    }
719
720    Ok(parts.join("/"))
721}
722
723async fn prune_empty_parent_dirs(key: &str, mut current: Option<&Path>, root: &Path) -> Result<()> {
724    while let Some(dir) = current {
725        if dir == root {
726            break;
727        }
728
729        match fs::remove_dir(dir).await {
730            Ok(()) => current = dir.parent(),
731            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => break,
732            Err(err) if err.kind() == std::io::ErrorKind::NotFound => break,
733            Err(err) => return Err(io_error(key, err)),
734        }
735    }
736
737    Ok(())
738}
739
740/// Private scratch names (the store lock and in-flight temp writes) are
741/// never objects: listings hide them and `resolve_key` rejects them, so the
742/// hidden set and the unaddressable set are identical. The match is
743/// deliberately narrow — key segments are otherwise allowed to start with a
744/// dot.
745fn is_scratch_name(name: &str) -> bool {
746    name == STORE_LOCK_FILE_NAME || (name.starts_with('.') && name.contains(".tmp-"))
747}
748
749#[allow(clippy::disallowed_methods)]
750fn temp_path(path: &Path) -> PathBuf {
751    // Local atomic writes need a unique sibling name; this timestamp is not durable state.
752    let file_name = path
753        .file_name()
754        .and_then(|name| name.to_str())
755        .unwrap_or("object");
756    let stamp = SystemTime::now()
757        .duration_since(UNIX_EPOCH)
758        .unwrap_or_default()
759        .as_nanos();
760
761    path.with_file_name(format!(".{file_name}.tmp-{}-{stamp}", std::process::id()))
762}
763
764fn map_create_error(key: &str, err: std::io::Error) -> ObjectStoreError {
765    if err.kind() == std::io::ErrorKind::AlreadyExists {
766        ObjectStoreError::PreconditionFailed {
767            object_key: key.to_owned(),
768        }
769    } else {
770        io_error(key, err)
771    }
772}
773
774fn io_error(key: &str, err: std::io::Error) -> ObjectStoreError {
775    if err.kind() == std::io::ErrorKind::PermissionDenied {
776        return ObjectStoreError::PermissionDenied {
777            object_key: key.to_owned(),
778            message: err.to_string(),
779        };
780    }
781    ObjectStoreError::transport(key, err.to_string())
782}
783
784#[cfg(test)]
785mod tests {
786    #![allow(clippy::panic)]
787    // Tests panic in unexpected match arms for precise diagnostics.
788
789    use super::LocalFsStore;
790    use super::{ByteRange, ObjectStore, ObjectStoreError, PutMode};
791    use crate::keys::{upload_session, wal_head};
792    use bytes::Bytes;
793    use std::fs;
794    use std::path::{Path, PathBuf};
795    use std::sync::Arc;
796    use std::time::{SystemTime, UNIX_EPOCH};
797    use tokio::sync::Barrier;
798
799    #[test]
800    fn construction_requires_atomic_rename_replace_support() {
801        let temp_dir = TestDir::new("construction-gate");
802        let result = LocalFsStore::new(temp_dir.path());
803
804        #[cfg(unix)]
805        assert!(result.is_ok());
806
807        #[cfg(not(unix))]
808        assert!(matches!(
809            result,
810            Err(ObjectStoreError::Configuration(message))
811                if message.contains("requires atomic rename-replace")
812                    && message.contains("Unix-family")
813        ));
814    }
815
816    #[cfg(unix)]
817    #[tokio::test]
818    async fn listing_tolerates_entries_that_vanish_mid_walk() {
819        let temp_dir = TestDir::new("listing-races");
820        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
821        let key = wal_head("ns-1");
822        store
823            .put(&key, Bytes::from_static(b"{}"), PutMode::Overwrite)
824            .await
825            .expect("seed object");
826
827        // A dangling path is exactly what the walk sees when a concurrent
828        // publish renames its scratch file away, or a concurrent delete
829        // removes an object, between enumeration and inspection: read_dir
830        // listed the entry but the stat answers NotFound. Broken symlinks
831        // reproduce that window deterministically.
832        let dir = temp_dir.path().join("namespaces/ns-1/wal");
833        std::os::unix::fs::symlink(dir.join("missing"), dir.join(".head.json.tmp-1-2"))
834            .expect("dangling scratch entry");
835        std::os::unix::fs::symlink(dir.join("missing"), dir.join("vanished.json"))
836            .expect("dangling plain entry");
837
838        let keys = store
839            .list_prefix("namespaces/ns-1/")
840            .await
841            .expect("listing succeeds despite dangling entries");
842        assert_eq!(keys, vec![key]);
843    }
844
845    #[tokio::test]
846    async fn a_head_of_a_missing_object_answers_gone() {
847        let temp_dir = TestDir::new("head-missing");
848        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
849
850        let answer = store.head(&wal_head("ns-1")).await.expect("head succeeds");
851        assert!(answer.is_none());
852    }
853
854    // A delete can land between `metadata_for_path`'s stat and its digest
855    // read; no single filesystem state makes the stat succeed and the read
856    // answer NotFound (both follow the same path resolution), so the race
857    // window cannot be staged whole. Each arm of the read is pinned
858    // directly instead.
859    #[tokio::test]
860    async fn a_digest_read_of_a_vanished_object_answers_gone() {
861        let temp_dir = TestDir::new("digest-vanished");
862        let vanished = temp_dir.path().join("vanished.json");
863
864        let answer = LocalFsStore::read_for_digest("namespaces/ns-1/wal/head.json", &vanished)
865            .await
866            .expect("a vanished object is an answer, not an error");
867        assert!(answer.is_none());
868    }
869
870    #[tokio::test]
871    async fn a_digest_read_that_fails_for_another_reason_stays_an_error() {
872        let temp_dir = TestDir::new("digest-error");
873        let directory = temp_dir.path().join("dir");
874        fs::create_dir(&directory).expect("create directory");
875
876        let error = LocalFsStore::read_for_digest("namespaces/ns-1/wal/head.json", &directory)
877            .await
878            .expect_err("reading a directory is not a missing object");
879        assert!(matches!(error, ObjectStoreError::Transport { .. }));
880    }
881
882    #[tokio::test]
883    async fn overwrite_refreshes_head_and_visible_bytes() {
884        let temp_dir = TestDir::new("overwrite");
885        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
886        let key = wal_head("ns-1");
887
888        let first = store
889            .put(
890                &key,
891                Bytes::from_static(br#"{"seq":1}"#),
892                PutMode::Overwrite,
893            )
894            .await
895            .expect("seed first object");
896        let second = store
897            .put(
898                &key,
899                Bytes::from_static(br#"{"seq":2}"#),
900                PutMode::Overwrite,
901            )
902            .await
903            .expect("overwrite object");
904
905        assert_eq!(
906            store.get(&key, None).await.expect("get object"),
907            Some(Bytes::from_static(br#"{"seq":2}"#))
908        );
909        let head = store
910            .head(&key)
911            .await
912            .expect("head object")
913            .expect("head exists");
914        assert_eq!(head.etag, second.etag);
915        assert_eq!(head.size_bytes, second.size_bytes);
916        assert_ne!(first, second);
917    }
918
919    /// The range contract a chunked reader depends on: ranges answer their
920    /// own bytes, an end past the object is truncated rather than refused,
921    /// and a start past the object or a descending range is refused.
922    #[tokio::test]
923    async fn ranged_reads_answer_their_range_and_refuse_impossible_ones() {
924        let temp_dir = TestDir::new("ranged-reads");
925        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
926        let key = wal_head("ns-1");
927        let payload = Bytes::from_static(b"0123456789");
928        store
929            .put(&key, payload.clone(), PutMode::Overwrite)
930            .await
931            .expect("seed object");
932
933        let range = |start_inclusive, end_exclusive| {
934            Some(ByteRange {
935                start_inclusive,
936                end_exclusive,
937            })
938        };
939        assert_eq!(
940            store.get(&key, range(0, 4)).await.expect("leading range"),
941            Some(Bytes::from_static(b"0123"))
942        );
943        assert_eq!(
944            store.get(&key, range(4, 7)).await.expect("middle range"),
945            Some(Bytes::from_static(b"456"))
946        );
947        assert_eq!(
948            store
949                .get(&key, range(7, 99))
950                .await
951                .expect("range past the end is truncated"),
952            Some(Bytes::from_static(b"789"))
953        );
954        assert_eq!(
955            store
956                .get(&key, range(10, 10))
957                .await
958                .expect("a range at the end is empty, not missing"),
959            Some(Bytes::new())
960        );
961        assert!(matches!(
962            store.get(&key, range(11, 12)).await,
963            Err(ObjectStoreError::InvalidRange { .. })
964        ));
965        assert!(matches!(
966            store.get(&key, range(6, 2)).await,
967            Err(ObjectStoreError::InvalidRange { .. })
968        ));
969        assert!(store
970            .get(&wal_head("ns-missing"), range(0, 4))
971            .await
972            .expect("a ranged read of a missing object is absent, not an error")
973            .is_none());
974    }
975
976    /// Readers race each overwrite rename to exercise replacement visibility across runtime workers.
977    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
978    async fn concurrent_readers_observe_complete_replacement_generations() {
979        const PAYLOAD_BYTES: usize = 16 * 1024;
980        const READER_COUNT: usize = 4;
981        const REPLACEMENT_COUNT: u8 = 32;
982
983        let temp_dir = TestDir::new("atomic-replacement");
984        let store = Arc::new(LocalFsStore::new(temp_dir.path()).expect("create local fs store"));
985        let key = wal_head("ns-atomic-replacement");
986        store
987            .put(
988                &key,
989                Bytes::from(vec![0; PAYLOAD_BYTES]),
990                PutMode::Overwrite,
991            )
992            .await
993            .expect("seed replacement object");
994
995        let round_barrier = Arc::new(Barrier::new(READER_COUNT + 1));
996        let mut readers = Vec::new();
997        for _ in 0..READER_COUNT {
998            let reader = Arc::clone(&store);
999            let reader_key = key.clone();
1000            let reader_barrier = Arc::clone(&round_barrier);
1001            readers.push(tokio::spawn(async move {
1002                for _ in 0..REPLACEMENT_COUNT {
1003                    reader_barrier.wait().await;
1004                    let bytes = reader
1005                        .get(&reader_key, None)
1006                        .await
1007                        .expect("read during replacement")
1008                        .expect("replacement key remains present");
1009                    assert_eq!(bytes.len(), PAYLOAD_BYTES);
1010                    let generation = bytes[0];
1011                    assert!(generation <= REPLACEMENT_COUNT);
1012                    assert!(bytes.iter().all(|byte| *byte == generation));
1013                    reader_barrier.wait().await;
1014                }
1015            }));
1016        }
1017
1018        for generation in 1..=REPLACEMENT_COUNT {
1019            round_barrier.wait().await;
1020            store
1021                .put(
1022                    &key,
1023                    Bytes::from(vec![generation; PAYLOAD_BYTES]),
1024                    PutMode::Overwrite,
1025                )
1026                .await
1027                .expect("replace object");
1028            round_barrier.wait().await;
1029        }
1030
1031        for reader in readers {
1032            reader.await.expect("reader task");
1033        }
1034    }
1035
1036    #[tokio::test]
1037    async fn delete_is_idempotent_and_head_reflects_removal() {
1038        let temp_dir = TestDir::new("delete");
1039        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1040        let key = upload_session("ns-1", "upl_00000000000000000000000000000001");
1041
1042        store
1043            .put_if_absent(&key, Bytes::from_static(br#"{"created":true}"#))
1044            .await
1045            .expect("seed upload object");
1046        assert!(store
1047            .head(&key)
1048            .await
1049            .expect("head before delete")
1050            .is_some());
1051        store.delete(&key).await.expect("delete existing object");
1052        store.delete(&key).await.expect("delete missing object");
1053        assert_eq!(store.head(&key).await.expect("head after delete"), None);
1054    }
1055
1056    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1057    async fn compare_and_swap_is_safe_across_store_instances() {
1058        let temp_dir = TestDir::new("cross-instance-cas");
1059        let key = wal_head("ns-cas");
1060        let seed = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1061        seed.put(&key, Bytes::from_static(b"0"), PutMode::CreateIfAbsent)
1062            .await
1063            .expect("seed counter");
1064
1065        let mut writers = Vec::new();
1066        for _ in 0..2 {
1067            let root = temp_dir.path().to_path_buf();
1068            let key = key.clone();
1069            writers.push(tokio::spawn(async move {
1070                // A separate instance models a separate process: it shares
1071                // no in-process mutex with its sibling, only the file lock.
1072                let store = LocalFsStore::new(&root).expect("create local fs store");
1073                for _ in 0..20 {
1074                    loop {
1075                        let body = store
1076                            .get_with_metadata(&key)
1077                            .await
1078                            .expect("read counter")
1079                            .expect("counter exists");
1080                        let value: u64 = std::str::from_utf8(&body.bytes)
1081                            .expect("utf8 counter")
1082                            .parse()
1083                            .expect("numeric counter");
1084                        let expected_etag = body.metadata.etag.expect("counter etag");
1085                        let put = store
1086                            .put(
1087                                &key,
1088                                Bytes::from((value + 1).to_string()),
1089                                PutMode::CompareAndSwap { expected_etag },
1090                            )
1091                            .await;
1092                        match put {
1093                            Ok(_) => break,
1094                            Err(ObjectStoreError::PreconditionFailed { .. }) => continue,
1095                            Err(err) => panic!("unexpected CAS error: {err}"),
1096                        }
1097                    }
1098                }
1099            }));
1100        }
1101        for writer in writers {
1102            writer.await.expect("writer task");
1103        }
1104
1105        let bytes = seed
1106            .get(&key, None)
1107            .await
1108            .expect("read final counter")
1109            .expect("counter exists");
1110        assert_eq!(std::str::from_utf8(&bytes).expect("utf8"), "40");
1111    }
1112
1113    #[tokio::test]
1114    async fn listings_hide_scratch_files_and_reject_scratch_keys() {
1115        let temp_dir = TestDir::new("scratch");
1116        let store = LocalFsStore::new(temp_dir.path()).expect("create local fs store");
1117        let key = wal_head("ns-scratch");
1118        store
1119            .put(&key, Bytes::from_static(b"{}"), PutMode::Overwrite)
1120            .await
1121            .expect("put object");
1122
1123        // The write above created the store lock; fake an in-flight temp
1124        // write next to the real object as well.
1125        assert!(temp_dir.path().join(super::STORE_LOCK_FILE_NAME).exists());
1126        let wal_dir = temp_dir.path().join("namespaces/ns-scratch/wal");
1127        fs::write(wal_dir.join(".head.json.tmp-123-456"), b"partial")
1128            .expect("write fake temp file");
1129
1130        let keys = store.list_prefix("").await.expect("list all");
1131        assert_eq!(keys, vec![key]);
1132
1133        let reserved = store.get(super::STORE_LOCK_FILE_NAME, None).await;
1134        assert!(matches!(
1135            reserved,
1136            Err(ObjectStoreError::InvalidKey { object_key, .. })
1137                if object_key == super::STORE_LOCK_FILE_NAME
1138        ));
1139        let temp_shaped = store
1140            .put(
1141                "namespaces/ns-scratch/control/.head.json.tmp-9-9",
1142                Bytes::from_static(b"x"),
1143                PutMode::Overwrite,
1144            )
1145            .await;
1146        assert!(matches!(
1147            temp_shaped,
1148            Err(ObjectStoreError::InvalidKey { .. })
1149        ));
1150    }
1151
1152    struct TestDir {
1153        path: PathBuf,
1154    }
1155
1156    impl TestDir {
1157        #[allow(clippy::disallowed_methods)]
1158        fn new(label: &str) -> Self {
1159            // Test-only unique paths are an entropy boundary, not protocol time.
1160            let stamp = SystemTime::now()
1161                .duration_since(UNIX_EPOCH)
1162                .unwrap_or_default()
1163                .as_nanos();
1164            let path = std::env::temp_dir().join(format!(
1165                "loonfs-local-fs-{label}-{}-{stamp}",
1166                std::process::id()
1167            ));
1168            fs::create_dir_all(&path).expect("create temp dir");
1169            Self { path }
1170        }
1171
1172        fn path(&self) -> &Path {
1173            &self.path
1174        }
1175    }
1176
1177    impl Drop for TestDir {
1178        fn drop(&mut self) {
1179            let _ = fs::remove_dir_all(&self.path);
1180        }
1181    }
1182}