1use 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#[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 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 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 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 #[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 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 #[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 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}