Skip to main content

localharness/filesystem/
native.rs

1//! Native OS filesystem implementation of [`Filesystem`].
2//!
3//! Wraps `tokio::fs` for the async surface and uses `spawn_blocking`
4//! around `walkdir` / `tempfile` so synchronous traversal and atomic
5//! writes don't block the async runtime.
6
7use std::io;
8use std::io::Write as _;
9use std::path::{Path, PathBuf};
10
11use async_trait::async_trait;
12use tempfile::NamedTempFile;
13use walkdir::WalkDir;
14
15use super::{DirEntry, EntryKind, Filesystem, Metadata, WalkEntry};
16use crate::error::{Error, Result};
17
18/// Filesystem backed by the host operating system.
19#[derive(Debug, Default, Clone, Copy)]
20pub struct NativeFilesystem;
21
22impl NativeFilesystem {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28fn classify(meta: &std::fs::Metadata, file_type: std::fs::FileType) -> EntryKind {
29    if file_type.is_symlink() {
30        EntryKind::Symlink
31    } else if meta.is_dir() {
32        EntryKind::Directory
33    } else if meta.is_file() {
34        EntryKind::File
35    } else {
36        EntryKind::Other
37    }
38}
39
40fn classify_meta_only(meta: &std::fs::Metadata) -> EntryKind {
41    let ft = meta.file_type();
42    if ft.is_symlink() {
43        EntryKind::Symlink
44    } else if meta.is_dir() {
45        EntryKind::Directory
46    } else if meta.is_file() {
47        EntryKind::File
48    } else {
49        EntryKind::Other
50    }
51}
52
53#[async_trait]
54impl Filesystem for NativeFilesystem {
55    async fn read(&self, path: &str) -> Result<Vec<u8>> {
56        let p = PathBuf::from(path);
57        tokio::fs::read(&p)
58            .await
59            .map_err(|e| Error::fs("read", path, format!("read({}): {e}", p.display())))
60    }
61
62    async fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<()> {
63        let target = PathBuf::from(path);
64        let parent: Option<PathBuf> = target
65            .parent()
66            .filter(|p| !p.as_os_str().is_empty())
67            .map(Path::to_path_buf);
68        let owned = bytes.to_vec();
69
70        tokio::task::spawn_blocking(move || -> Result<()> {
71            if let Some(p) = &parent {
72                std::fs::create_dir_all(p).map_err(|e| {
73                    Error::fs(
74                        "create_dir_all",
75                        p.display().to_string(),
76                        format!("create_dir_all({}): {e}", p.display()),
77                    )
78                })?;
79            }
80            let dir = parent.as_deref().unwrap_or(Path::new("."));
81            let mut tmp = NamedTempFile::new_in(dir).map_err(|e| {
82                Error::fs(
83                    "tempfile",
84                    dir.display().to_string(),
85                    format!("tempfile in {}: {e}", dir.display()),
86                )
87            })?;
88            tmp.write_all(&owned)
89                .map_err(|e| Error::fs("write", "", format!("write: {e}")))?;
90            tmp.persist(&target).map_err(|e| {
91                Error::fs(
92                    "rename",
93                    target.display().to_string(),
94                    format!("rename to {}: {e}", target.display()),
95                )
96            })?;
97            Ok(())
98        })
99        .await
100        .map_err(|e| Error::fs("write_atomic join", "", format!("write_atomic join: {e}")))?
101    }
102
103    async fn metadata(&self, path: &str) -> Result<Option<Metadata>> {
104        let p = PathBuf::from(path);
105        // `symlink_metadata` (lstat) does NOT follow symlinks, so a symlink
106        // classifies as `EntryKind::Symlink` rather than its target's kind —
107        // matching `walk()` (follow_links=false) and `delete()`.
108        match tokio::fs::symlink_metadata(&p).await {
109            Ok(meta) => {
110                let kind = classify_meta_only(&meta);
111                Ok(Some(Metadata {
112                    kind,
113                    size: meta.len(),
114                }))
115            }
116            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
117            Err(e) => Err(Error::fs("metadata", path, format!("metadata({}): {e}", p.display()))),
118        }
119    }
120
121    async fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
122        let p = PathBuf::from(path);
123        let mut read = tokio::fs::read_dir(&p)
124            .await
125            .map_err(|e| Error::fs("read_dir", path, format!("read_dir({}): {e}", p.display())))?;
126        let mut entries: Vec<DirEntry> = Vec::new();
127        while let Some(entry) = read
128            .next_entry()
129            .await
130            .map_err(|e| Error::fs("next_entry", path, format!("next_entry: {e}")))?
131        {
132            let meta = entry
133                .metadata()
134                .await
135                .map_err(|e| Error::fs("metadata", path, format!("metadata: {e}")))?;
136            let ft = meta.file_type();
137            let kind = classify(&meta, ft);
138            let size = if matches!(kind, EntryKind::File) {
139                Some(meta.len())
140            } else {
141                None
142            };
143            entries.push(DirEntry {
144                name: entry.file_name().to_string_lossy().into_owned(),
145                kind,
146                size,
147            });
148        }
149        entries.sort_by(|a, b| a.name.cmp(&b.name));
150        Ok(entries)
151    }
152
153    async fn walk(&self, path: &str, max_depth: Option<usize>) -> Result<Vec<WalkEntry>> {
154        let root = PathBuf::from(path);
155        let result = tokio::task::spawn_blocking(move || -> Vec<WalkEntry> {
156            let mut walker = WalkDir::new(&root).follow_links(false);
157            if let Some(d) = max_depth {
158                walker = walker.max_depth(d);
159            }
160            // Hard cap on entries collected. find_file/search_directory
161            // cap their own RESULTS, but they collect the whole walk first,
162            // so without this a walk over a multi-million-entry tree would
163            // exhaust memory. 200k is far beyond any real workspace search.
164            const MAX_WALK_ENTRIES: usize = 200_000;
165            let mut out = Vec::new();
166            for entry in walker.into_iter().filter_map(|e| e.ok()) {
167                if out.len() >= MAX_WALK_ENTRIES {
168                    break;
169                }
170                let ft = entry.file_type();
171                let kind = if ft.is_symlink() {
172                    EntryKind::Symlink
173                } else if ft.is_dir() {
174                    EntryKind::Directory
175                } else if ft.is_file() {
176                    EntryKind::File
177                } else {
178                    EntryKind::Other
179                };
180                let size = if matches!(kind, EntryKind::File) {
181                    entry.metadata().ok().map(|m| m.len())
182                } else {
183                    None
184                };
185                out.push(WalkEntry {
186                    path: entry.path().display().to_string(),
187                    kind,
188                    size,
189                });
190            }
191            out
192        })
193        .await
194        .map_err(|e| Error::fs("walk join", path, format!("walk join: {e}")))?;
195        Ok(result)
196    }
197
198    async fn delete(&self, path: &str) -> Result<()> {
199        let p = PathBuf::from(path);
200        let meta = tokio::fs::symlink_metadata(&p)
201            .await
202            .map_err(|e| Error::fs("delete", path, format!("delete({path}): {e}")))?;
203        if meta.is_dir() {
204            tokio::fs::remove_dir_all(&p)
205                .await
206                .map_err(|e| Error::fs("delete", path, format!("delete({path}): {e}")))?;
207        } else {
208            tokio::fs::remove_file(&p)
209                .await
210                .map_err(|e| Error::fs("delete", path, format!("delete({path}): {e}")))?;
211        }
212        Ok(())
213    }
214
215    /// Native rename uses `std::fs::rename` (atomic on the same
216    /// filesystem). Overrides the default copy+delete fallback for
217    /// efficiency and atomicity.
218    async fn rename(&self, from: &str, to: &str) -> Result<()> {
219        tokio::fs::rename(from, to)
220            .await
221            .map_err(|e| Error::fs("rename", from, format!("rename({from} -> {to}): {e}")))
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn unique_dir(label: &str) -> PathBuf {
230        let mut p = std::env::temp_dir();
231        p.push(format!("lh_nfs_{label}_{}", uuid::Uuid::new_v4()));
232        std::fs::create_dir_all(&p).unwrap();
233        p
234    }
235
236    fn touch(dir: &Path, rel: &str, content: &[u8]) {
237        let mut p = dir.to_path_buf();
238        for part in rel.split('/') {
239            p.push(part);
240        }
241        if let Some(parent) = p.parent() {
242            std::fs::create_dir_all(parent).unwrap();
243        }
244        std::fs::write(&p, content).unwrap();
245    }
246
247    #[tokio::test]
248    async fn metadata_returns_none_for_missing_path() {
249        let fs = NativeFilesystem::new();
250        let res = fs
251            .metadata("/definitely/does/not/exist/lh-nfs-test-zzz")
252            .await
253            .unwrap();
254        assert!(res.is_none());
255    }
256
257    #[tokio::test]
258    async fn metadata_reports_size_and_kind_for_file() {
259        let dir = unique_dir("meta");
260        touch(&dir, "x.txt", b"abcdef");
261        let fs = NativeFilesystem::new();
262        let meta = fs
263            .metadata(&dir.join("x.txt").display().to_string())
264            .await
265            .unwrap()
266            .unwrap();
267        assert_eq!(meta.kind, EntryKind::File);
268        assert_eq!(meta.size, 6);
269
270        let dir_meta = fs
271            .metadata(&dir.display().to_string())
272            .await
273            .unwrap()
274            .unwrap();
275        assert_eq!(dir_meta.kind, EntryKind::Directory);
276        std::fs::remove_dir_all(&dir).ok();
277    }
278
279    #[tokio::test]
280    async fn read_returns_full_bytes() {
281        let dir = unique_dir("read");
282        touch(&dir, "blob.bin", &[0u8, 1, 2, 3, 255]);
283        let fs = NativeFilesystem::new();
284        let bytes = fs
285            .read(&dir.join("blob.bin").display().to_string())
286            .await
287            .unwrap();
288        assert_eq!(bytes, vec![0, 1, 2, 3, 255]);
289        std::fs::remove_dir_all(&dir).ok();
290    }
291
292    /// Slice C1: fs-op failures construct the typed `Error::Fs` (CORE_IO,
293    /// Display verbatim, structurally attributed) — never the Other catch-all.
294    #[tokio::test]
295    async fn errors_are_typed_fs_variants() {
296        let fs = NativeFilesystem::new();
297        let err = fs.read("/definitely/does/not/exist/lh-nfs-zzz").await.unwrap_err();
298        assert!(matches!(&err, Error::Fs { op, .. } if op == "read"), "{err:?}");
299        assert_eq!(err.code(), crate::error_codes::CORE_IO);
300        assert!(err.to_string().starts_with("read("), "{err}");
301    }
302
303    #[tokio::test]
304    async fn write_atomic_creates_parent_dirs_and_replaces() {
305        let dir = unique_dir("write");
306        let target = dir.join("a/b/c.txt");
307        let fs = NativeFilesystem::new();
308        fs.write_atomic(&target.display().to_string(), b"first")
309            .await
310            .unwrap();
311        assert_eq!(std::fs::read(&target).unwrap(), b"first");
312
313        // Overwrites the existing file.
314        fs.write_atomic(&target.display().to_string(), b"second")
315            .await
316            .unwrap();
317        assert_eq!(std::fs::read(&target).unwrap(), b"second");
318        std::fs::remove_dir_all(&dir).ok();
319    }
320
321    #[tokio::test]
322    async fn read_dir_sorts_by_name() {
323        let dir = unique_dir("sort");
324        touch(&dir, "c", b"");
325        touch(&dir, "a", b"");
326        touch(&dir, "b", b"");
327        let fs = NativeFilesystem::new();
328        let entries = fs.read_dir(&dir.display().to_string()).await.unwrap();
329        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
330        assert_eq!(names, vec!["a", "b", "c"]);
331        std::fs::remove_dir_all(&dir).ok();
332    }
333
334    #[tokio::test]
335    async fn read_dir_carries_size_for_files_only() {
336        let dir = unique_dir("size");
337        touch(&dir, "file.txt", b"hello");
338        std::fs::create_dir_all(dir.join("inner")).unwrap();
339        let fs = NativeFilesystem::new();
340        let entries = fs.read_dir(&dir.display().to_string()).await.unwrap();
341        let file = entries.iter().find(|e| e.name == "file.txt").unwrap();
342        let inner = entries.iter().find(|e| e.name == "inner").unwrap();
343        assert_eq!(file.size, Some(5));
344        assert_eq!(file.kind, EntryKind::File);
345        assert_eq!(inner.size, None);
346        assert_eq!(inner.kind, EntryKind::Directory);
347        std::fs::remove_dir_all(&dir).ok();
348    }
349
350    // metadata() must lstat (NOT follow symlinks) so a symlink classifies as
351    // EntryKind::Symlink — matching walk()/delete(). Unix-only: creating a
352    // symlink on Windows needs elevated privileges.
353    #[cfg(unix)]
354    #[tokio::test]
355    async fn metadata_classifies_symlink_without_following() {
356        let dir = unique_dir("symlink");
357        touch(&dir, "target.txt", b"hi");
358        let link = dir.join("link.txt");
359        std::os::unix::fs::symlink(dir.join("target.txt"), &link).unwrap();
360        let fs = NativeFilesystem::new();
361        let meta = fs
362            .metadata(&link.display().to_string())
363            .await
364            .unwrap()
365            .unwrap();
366        assert_eq!(meta.kind, EntryKind::Symlink);
367        std::fs::remove_dir_all(&dir).ok();
368    }
369
370    #[tokio::test]
371    async fn walk_with_max_depth_caps_recursion() {
372        let dir = unique_dir("walk");
373        touch(&dir, "top.txt", b"");
374        touch(&dir, "a/mid.txt", b"");
375        touch(&dir, "a/b/deep.txt", b"");
376        let fs = NativeFilesystem::new();
377
378        let all = fs
379            .walk(&dir.display().to_string(), Some(2))
380            .await
381            .unwrap();
382        // depth 0 = root dir; depth 1 = top.txt + a; depth 2 = a/mid.txt + a/b.
383        // a/b/deep.txt (depth 3) excluded.
384        let deep_visible = all.iter().any(|e| e.path.ends_with("deep.txt"));
385        assert!(!deep_visible, "max_depth=2 should hide depth-3 entries");
386
387        let unbounded = fs.walk(&dir.display().to_string(), None).await.unwrap();
388        assert!(unbounded.iter().any(|e| e.path.ends_with("deep.txt")));
389        std::fs::remove_dir_all(&dir).ok();
390    }
391}