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 write_file_with_mode(&self, path: &Path, contents: &[u8], mode: u32) -> Result<()> {
58        use std::io::Write as _;
59        use std::os::unix::fs::OpenOptionsExt;
60        // Open with `mode` set at creation time so the file never
61        // lives at the umask-default mode. Truncates if the file
62        // already exists; mode is applied to a freshly-created
63        // file but NOT to an existing one (POSIX `open(2)`
64        // semantics) — we set it explicitly afterward so
65        // overwriting an existing 0644 file still ends at the
66        // requested mode. The window between the truncating open
67        // and the chmod is narrower than `write_file` +
68        // `set_permissions` because no bytes have been written
69        // yet (no readable plaintext at risk).
70        let mut file = fs::OpenOptions::new()
71            .write(true)
72            .create(true)
73            .truncate(true)
74            .mode(mode)
75            .open(path)
76            .map_err(|e| fs_err(path, e))?;
77        // Tighten the mode regardless of whether the file already
78        // existed, so the plaintext never sits at a permissive
79        // mode while the bytes are written.
80        let perms = fs::Permissions::from_mode(mode);
81        fs::set_permissions(path, perms).map_err(|e| fs_err(path, e))?;
82        file.write_all(contents).map_err(|e| fs_err(path, e))?;
83        file.sync_all().map_err(|e| fs_err(path, e))?;
84        Ok(())
85    }
86
87    fn mkdir_all(&self, path: &Path) -> Result<()> {
88        fs::create_dir_all(path).map_err(|e| fs_err(path, e))
89    }
90
91    fn symlink(&self, original: &Path, link: &Path) -> Result<()> {
92        std::os::unix::fs::symlink(original, link).map_err(|e| fs_err(link, e))
93    }
94
95    fn readlink(&self, path: &Path) -> Result<PathBuf> {
96        fs::read_link(path).map_err(|e| fs_err(path, e))
97    }
98
99    fn remove_file(&self, path: &Path) -> Result<()> {
100        fs::remove_file(path).map_err(|e| fs_err(path, e))
101    }
102
103    fn remove_dir_all(&self, path: &Path) -> Result<()> {
104        fs::remove_dir_all(path).map_err(|e| fs_err(path, e))
105    }
106
107    fn exists(&self, path: &Path) -> bool {
108        path.exists()
109    }
110
111    fn is_symlink(&self, path: &Path) -> bool {
112        path.symlink_metadata()
113            .map(|m| m.file_type().is_symlink())
114            .unwrap_or(false)
115    }
116
117    fn is_dir(&self, path: &Path) -> bool {
118        path.is_dir()
119    }
120
121    fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
122        let entries = fs::read_dir(path).map_err(|e| fs_err(path, e))?;
123
124        let mut result = Vec::new();
125        for entry in entries {
126            let entry = entry.map_err(|e| fs_err(path, e))?;
127            let file_type = entry.file_type().map_err(|e| fs_err(entry.path(), e))?;
128            let name = entry.file_name().to_string_lossy().into_owned();
129
130            result.push(DirEntry {
131                path: entry.path(),
132                name,
133                is_dir: file_type.is_dir(),
134                is_file: file_type.is_file(),
135                is_symlink: file_type.is_symlink(),
136            });
137        }
138
139        result.sort_by(|a, b| a.name.cmp(&b.name));
140        Ok(result)
141    }
142
143    fn rename(&self, from: &Path, to: &Path) -> Result<()> {
144        fs::rename(from, to).map_err(|e| fs_err(from, e))
145    }
146
147    fn copy_file(&self, from: &Path, to: &Path) -> Result<()> {
148        fs::copy(from, to).map(|_| ()).map_err(|e| fs_err(from, e))
149    }
150
151    fn set_permissions(&self, path: &Path, mode: u32) -> Result<()> {
152        let perms = fs::Permissions::from_mode(mode);
153        fs::set_permissions(path, perms).map_err(|e| fs_err(path, e))
154    }
155
156    fn modified(&self, path: &Path) -> Result<std::time::SystemTime> {
157        fs::metadata(path)
158            .and_then(|m| m.modified())
159            .map_err(|e| fs_err(path, e))
160    }
161
162    fn set_modified(&self, path: &Path, time: std::time::SystemTime) -> Result<()> {
163        // `File::set_modified` (stable since 1.75) needs an existing
164        // file handle. We deliberately open with `.write(true)` (NOT
165        // `.create(true)`, NOT `.truncate(true)`) so we get a handle
166        // to the existing file without touching its content.
167        let file = fs::OpenOptions::new()
168            .write(true)
169            .open(path)
170            .map_err(|e| fs_err(path, e))?;
171        file.set_modified(time).map_err(|e| fs_err(path, e))
172    }
173}
174
175fn metadata_from_std(meta: &fs::Metadata, is_symlink: bool) -> FsMetadata {
176    FsMetadata {
177        is_file: meta.is_file(),
178        is_dir: meta.is_dir(),
179        is_symlink,
180        len: meta.len(),
181        mode: meta.permissions().mode(),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use tempfile::TempDir;
189
190    #[test]
191    fn write_and_read_file() {
192        let tmp = TempDir::new().unwrap();
193        let fs = OsFs::new();
194        let path = tmp.path().join("hello.txt");
195
196        fs.write_file(&path, b"hello world").unwrap();
197        let contents = fs.read_to_string(&path).unwrap();
198        assert_eq!(contents, "hello world");
199    }
200
201    #[test]
202    fn read_file_bytes() {
203        let tmp = TempDir::new().unwrap();
204        let fs = OsFs::new();
205        let path = tmp.path().join("data.bin");
206
207        let data = vec![0u8, 1, 2, 255];
208        fs.write_file(&path, &data).unwrap();
209        let read_back = fs.read_file(&path).unwrap();
210        assert_eq!(read_back, data);
211    }
212
213    #[test]
214    fn mkdir_all_creates_nested_dirs() {
215        let tmp = TempDir::new().unwrap();
216        let fs = OsFs::new();
217        let deep = tmp.path().join("a").join("b").join("c");
218
219        fs.mkdir_all(&deep).unwrap();
220        assert!(fs.is_dir(&deep));
221    }
222
223    #[test]
224    fn symlink_and_readlink_roundtrip() {
225        let tmp = TempDir::new().unwrap();
226        let fs = OsFs::new();
227        let original = tmp.path().join("original.txt");
228        let link = tmp.path().join("link.txt");
229
230        fs.write_file(&original, b"content").unwrap();
231        fs.symlink(&original, &link).unwrap();
232
233        assert!(fs.is_symlink(&link));
234        assert_eq!(fs.readlink(&link).unwrap(), original);
235
236        // Reading through the symlink works
237        let content = fs.read_to_string(&link).unwrap();
238        assert_eq!(content, "content");
239    }
240
241    #[test]
242    fn stat_follows_symlinks() {
243        let tmp = TempDir::new().unwrap();
244        let fs = OsFs::new();
245        let original = tmp.path().join("file.txt");
246        let link = tmp.path().join("link.txt");
247
248        fs.write_file(&original, b"data").unwrap();
249        fs.symlink(&original, &link).unwrap();
250
251        let meta = fs.stat(&link).unwrap();
252        assert!(meta.is_file);
253        assert!(!meta.is_symlink);
254    }
255
256    #[test]
257    fn lstat_does_not_follow_symlinks() {
258        let tmp = TempDir::new().unwrap();
259        let fs = OsFs::new();
260        let original = tmp.path().join("file.txt");
261        let link = tmp.path().join("link.txt");
262
263        fs.write_file(&original, b"data").unwrap();
264        fs.symlink(&original, &link).unwrap();
265
266        let meta = fs.lstat(&link).unwrap();
267        assert!(meta.is_symlink);
268    }
269
270    #[test]
271    fn exists_and_is_dir() {
272        let tmp = TempDir::new().unwrap();
273        let fs = OsFs::new();
274
275        assert!(fs.exists(tmp.path()));
276        assert!(fs.is_dir(tmp.path()));
277        assert!(!fs.exists(&tmp.path().join("nope")));
278    }
279
280    #[test]
281    fn read_dir_sorted() {
282        let tmp = TempDir::new().unwrap();
283        let fs = OsFs::new();
284
285        fs.write_file(&tmp.path().join("c.txt"), b"").unwrap();
286        fs.write_file(&tmp.path().join("a.txt"), b"").unwrap();
287        fs.write_file(&tmp.path().join("b.txt"), b"").unwrap();
288
289        let entries = fs.read_dir(tmp.path()).unwrap();
290        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
291        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
292    }
293
294    #[test]
295    fn remove_file_and_remove_dir_all() {
296        let tmp = TempDir::new().unwrap();
297        let fs = OsFs::new();
298
299        let file = tmp.path().join("file.txt");
300        fs.write_file(&file, b"x").unwrap();
301        assert!(fs.exists(&file));
302        fs.remove_file(&file).unwrap();
303        assert!(!fs.exists(&file));
304
305        let dir = tmp.path().join("subdir");
306        fs.mkdir_all(&dir.join("nested")).unwrap();
307        fs.write_file(&dir.join("nested").join("f.txt"), b"y")
308            .unwrap();
309        assert!(fs.exists(&dir));
310        fs.remove_dir_all(&dir).unwrap();
311        assert!(!fs.exists(&dir));
312    }
313
314    #[test]
315    fn rename_file() {
316        let tmp = TempDir::new().unwrap();
317        let fs = OsFs::new();
318
319        let from = tmp.path().join("old.txt");
320        let to = tmp.path().join("new.txt");
321        fs.write_file(&from, b"moved").unwrap();
322        fs.rename(&from, &to).unwrap();
323
324        assert!(!fs.exists(&from));
325        assert_eq!(fs.read_to_string(&to).unwrap(), "moved");
326    }
327
328    #[test]
329    fn copy_file_preserves_content() {
330        let tmp = TempDir::new().unwrap();
331        let fs = OsFs::new();
332
333        let from = tmp.path().join("src.txt");
334        let to = tmp.path().join("dst.txt");
335        fs.write_file(&from, b"copied").unwrap();
336        fs.copy_file(&from, &to).unwrap();
337
338        assert!(fs.exists(&from));
339        assert_eq!(fs.read_to_string(&to).unwrap(), "copied");
340    }
341
342    #[test]
343    fn error_contains_path() {
344        let fs = OsFs::new();
345        let bad_path = Path::new("/nonexistent/path/to/file.txt");
346
347        let err = fs.read_file(bad_path).unwrap_err();
348        let msg = err.to_string();
349        assert!(
350            msg.contains("/nonexistent/path/to/file.txt"),
351            "error should contain the path: {msg}"
352        );
353    }
354
355    #[test]
356    fn set_permissions_works() {
357        let tmp = TempDir::new().unwrap();
358        let fs = OsFs::new();
359
360        let file = tmp.path().join("script.sh");
361        fs.write_file(&file, b"#!/bin/sh").unwrap();
362        fs.set_permissions(&file, 0o755).unwrap();
363
364        let meta = std::fs::metadata(&file).unwrap();
365        assert_eq!(meta.permissions().mode() & 0o777, 0o755);
366    }
367
368    // Compile-time check: Fs must be object-safe
369    #[allow(dead_code)]
370    fn assert_object_safe(_: &dyn Fs) {}
371}