Skip to main content

dodot_lib/fs/
os.rs

1use std::fs;
2use std::os::unix::fs::PermissionsExt;
3use std::path::{Path, PathBuf};
4
5use crate::error::fs_err;
6use crate::fs::{DirEntry, Fs, FsMetadata};
7use crate::Result;
8
9/// Filesystem implementation that delegates to `std::fs`.
10///
11/// Every `io::Error` is wrapped with the path that caused it via
12/// [`DodotError::Fs`](crate::DodotError::Fs).
13#[derive(Debug, Clone, Copy)]
14pub struct OsFs;
15
16impl OsFs {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22impl Default for OsFs {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl Fs for OsFs {
29    fn stat(&self, path: &Path) -> Result<FsMetadata> {
30        let meta = fs::metadata(path).map_err(|e| fs_err(path, e))?;
31        Ok(metadata_from_std(&meta, false))
32    }
33
34    fn lstat(&self, path: &Path) -> Result<FsMetadata> {
35        let meta = fs::symlink_metadata(path).map_err(|e| fs_err(path, e))?;
36        let is_symlink = meta.file_type().is_symlink();
37        Ok(metadata_from_std(&meta, is_symlink))
38    }
39
40    fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send + Sync>> {
41        let f = fs::File::open(path).map_err(|e| fs_err(path, e))?;
42        Ok(Box::new(f))
43    }
44
45    fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
46        fs::read(path).map_err(|e| fs_err(path, e))
47    }
48
49    fn read_to_string(&self, path: &Path) -> Result<String> {
50        fs::read_to_string(path).map_err(|e| fs_err(path, e))
51    }
52
53    fn write_file(&self, path: &Path, contents: &[u8]) -> Result<()> {
54        fs::write(path, contents).map_err(|e| fs_err(path, e))
55    }
56
57    fn mkdir_all(&self, path: &Path) -> Result<()> {
58        fs::create_dir_all(path).map_err(|e| fs_err(path, e))
59    }
60
61    fn symlink(&self, original: &Path, link: &Path) -> Result<()> {
62        std::os::unix::fs::symlink(original, link).map_err(|e| fs_err(link, e))
63    }
64
65    fn readlink(&self, path: &Path) -> Result<PathBuf> {
66        fs::read_link(path).map_err(|e| fs_err(path, e))
67    }
68
69    fn remove_file(&self, path: &Path) -> Result<()> {
70        fs::remove_file(path).map_err(|e| fs_err(path, e))
71    }
72
73    fn remove_dir_all(&self, path: &Path) -> Result<()> {
74        fs::remove_dir_all(path).map_err(|e| fs_err(path, e))
75    }
76
77    fn exists(&self, path: &Path) -> bool {
78        path.exists()
79    }
80
81    fn is_symlink(&self, path: &Path) -> bool {
82        path.symlink_metadata()
83            .map(|m| m.file_type().is_symlink())
84            .unwrap_or(false)
85    }
86
87    fn is_dir(&self, path: &Path) -> bool {
88        path.is_dir()
89    }
90
91    fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
92        let entries = fs::read_dir(path).map_err(|e| fs_err(path, e))?;
93
94        let mut result = Vec::new();
95        for entry in entries {
96            let entry = entry.map_err(|e| fs_err(path, e))?;
97            let file_type = entry.file_type().map_err(|e| fs_err(entry.path(), e))?;
98            let name = entry.file_name().to_string_lossy().into_owned();
99
100            result.push(DirEntry {
101                path: entry.path(),
102                name,
103                is_dir: file_type.is_dir(),
104                is_file: file_type.is_file(),
105                is_symlink: file_type.is_symlink(),
106            });
107        }
108
109        result.sort_by(|a, b| a.name.cmp(&b.name));
110        Ok(result)
111    }
112
113    fn rename(&self, from: &Path, to: &Path) -> Result<()> {
114        fs::rename(from, to).map_err(|e| fs_err(from, e))
115    }
116
117    fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
118        fs::copy(from, to).map(|_| ()).map_err(|e| fs_err(from, e))
119    }
120
121    fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> {
122        let perms = fs::Permissions::from_mode(mode);
123        fs::set_permissions(path, perms).map_err(|e| fs_err(path, e))
124    }
125
126    fn modified(&self, path: &Path) -> Result<std::time::SystemTime> {
127        fs::metadata(path)
128            .and_then(|m| m.modified())
129            .map_err(|e| fs_err(path, e))
130    }
131
132    fn set_modified(&self, path: &Path, time: std::time::SystemTime) -> Result<()> {
133        // `File::set_modified` (stable since 1.75) needs an existing
134        // file handle. We deliberately open with `.write(true)` (NOT
135        // `.create(true)`, NOT `.truncate(true)`) so we get a handle
136        // to the existing file without touching its content.
137        let file = fs::OpenOptions::new()
138            .write(true)
139            .open(path)
140            .map_err(|e| fs_err(path, e))?;
141        file.set_modified(time).map_err(|e| fs_err(path, e))
142    }
143}
144
145fn metadata_from_std(meta: &fs::Metadata, is_symlink: bool) -> FsMetadata {
146    FsMetadata {
147        is_file: meta.is_file(),
148        is_dir: meta.is_dir(),
149        is_symlink,
150        len: meta.len(),
151        mode: meta.permissions().mode(),
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use tempfile::TempDir;
159
160    #[test]
161    fn write_and_read_file() {
162        let tmp = TempDir::new().unwrap();
163        let fs = OsFs::new();
164        let path = tmp.path().join("hello.txt");
165
166        fs.write_file(&path, b"hello world").unwrap();
167        let contents = fs.read_to_string(&path).unwrap();
168        assert_eq!(contents, "hello world");
169    }
170
171    #[test]
172    fn read_file_bytes() {
173        let tmp = TempDir::new().unwrap();
174        let fs = OsFs::new();
175        let path = tmp.path().join("data.bin");
176
177        let data = vec![0u8, 1, 2, 255];
178        fs.write_file(&path, &data).unwrap();
179        let read_back = fs.read_file(&path).unwrap();
180        assert_eq!(read_back, data);
181    }
182
183    #[test]
184    fn mkdir_all_creates_nested_dirs() {
185        let tmp = TempDir::new().unwrap();
186        let fs = OsFs::new();
187        let deep = tmp.path().join("a").join("b").join("c");
188
189        fs.mkdir_all(&deep).unwrap();
190        assert!(fs.is_dir(&deep));
191    }
192
193    #[test]
194    fn symlink_and_readlink_roundtrip() {
195        let tmp = TempDir::new().unwrap();
196        let fs = OsFs::new();
197        let original = tmp.path().join("original.txt");
198        let link = tmp.path().join("link.txt");
199
200        fs.write_file(&original, b"content").unwrap();
201        fs.symlink(&original, &link).unwrap();
202
203        assert!(fs.is_symlink(&link));
204        assert_eq!(fs.readlink(&link).unwrap(), original);
205
206        // Reading through the symlink works
207        let content = fs.read_to_string(&link).unwrap();
208        assert_eq!(content, "content");
209    }
210
211    #[test]
212    fn stat_follows_symlinks() {
213        let tmp = TempDir::new().unwrap();
214        let fs = OsFs::new();
215        let original = tmp.path().join("file.txt");
216        let link = tmp.path().join("link.txt");
217
218        fs.write_file(&original, b"data").unwrap();
219        fs.symlink(&original, &link).unwrap();
220
221        let meta = fs.stat(&link).unwrap();
222        assert!(meta.is_file);
223        assert!(!meta.is_symlink);
224    }
225
226    #[test]
227    fn lstat_does_not_follow_symlinks() {
228        let tmp = TempDir::new().unwrap();
229        let fs = OsFs::new();
230        let original = tmp.path().join("file.txt");
231        let link = tmp.path().join("link.txt");
232
233        fs.write_file(&original, b"data").unwrap();
234        fs.symlink(&original, &link).unwrap();
235
236        let meta = fs.lstat(&link).unwrap();
237        assert!(meta.is_symlink);
238    }
239
240    #[test]
241    fn exists_and_is_dir() {
242        let tmp = TempDir::new().unwrap();
243        let fs = OsFs::new();
244
245        assert!(fs.exists(tmp.path()));
246        assert!(fs.is_dir(tmp.path()));
247        assert!(!fs.exists(&tmp.path().join("nope")));
248    }
249
250    #[test]
251    fn read_dir_sorted() {
252        let tmp = TempDir::new().unwrap();
253        let fs = OsFs::new();
254
255        fs.write_file(&tmp.path().join("c.txt"), b"").unwrap();
256        fs.write_file(&tmp.path().join("a.txt"), b"").unwrap();
257        fs.write_file(&tmp.path().join("b.txt"), b"").unwrap();
258
259        let entries = fs.read_dir(tmp.path()).unwrap();
260        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
261        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
262    }
263
264    #[test]
265    fn remove_file_and_remove_dir_all() {
266        let tmp = TempDir::new().unwrap();
267        let fs = OsFs::new();
268
269        let file = tmp.path().join("file.txt");
270        fs.write_file(&file, b"x").unwrap();
271        assert!(fs.exists(&file));
272        fs.remove_file(&file).unwrap();
273        assert!(!fs.exists(&file));
274
275        let dir = tmp.path().join("subdir");
276        fs.mkdir_all(&dir.join("nested")).unwrap();
277        fs.write_file(&dir.join("nested").join("f.txt"), b"y")
278            .unwrap();
279        assert!(fs.exists(&dir));
280        fs.remove_dir_all(&dir).unwrap();
281        assert!(!fs.exists(&dir));
282    }
283
284    #[test]
285    fn rename_file() {
286        let tmp = TempDir::new().unwrap();
287        let fs = OsFs::new();
288
289        let from = tmp.path().join("old.txt");
290        let to = tmp.path().join("new.txt");
291        fs.write_file(&from, b"moved").unwrap();
292        fs.rename(&from, &to).unwrap();
293
294        assert!(!fs.exists(&from));
295        assert_eq!(fs.read_to_string(&to).unwrap(), "moved");
296    }
297
298    #[test]
299    fn copy_file_preserves_content() {
300        let tmp = TempDir::new().unwrap();
301        let fs = OsFs::new();
302
303        let from = tmp.path().join("src.txt");
304        let to = tmp.path().join("dst.txt");
305        fs.write_file(&from, b"copied").unwrap();
306        fs.copy_file(&from, &to).unwrap();
307
308        assert!(fs.exists(&from));
309        assert_eq!(fs.read_to_string(&to).unwrap(), "copied");
310    }
311
312    #[test]
313    fn error_contains_path() {
314        let fs = OsFs::new();
315        let bad_path = Path::new("/nonexistent/path/to/file.txt");
316
317        let err = fs.read_file(bad_path).unwrap_err();
318        let msg = err.to_string();
319        assert!(
320            msg.contains("/nonexistent/path/to/file.txt"),
321            "error should contain the path: {msg}"
322        );
323    }
324
325    #[test]
326    fn set_permissions_works() {
327        let tmp = TempDir::new().unwrap();
328        let fs = OsFs::new();
329
330        let file = tmp.path().join("script.sh");
331        fs.write_file(&file, b"#!/bin/sh").unwrap();
332        fs.set_permissions(&file, 0o755).unwrap();
333
334        let meta = std::fs::metadata(&file).unwrap();
335        assert_eq!(meta.permissions().mode() & 0o777, 0o755);
336    }
337
338    // Compile-time check: Fs must be object-safe
339    #[allow(dead_code)]
340    fn assert_object_safe(_: &dyn Fs) {}
341}