Skip to main content

foundry_common/
fs.rs

1//! Contains various `std::fs` wrapper functions that also contain the target path in their errors.
2
3use crate::errors::FsPathError;
4use flate2::{Compression, read::GzDecoder, write::GzEncoder};
5use serde::{Serialize, de::DeserializeOwned};
6use std::{
7    fs::{self, File},
8    io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write},
9    path::{Component, Path, PathBuf},
10};
11
12#[cfg(unix)]
13use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
14
15/// The [`fs`](self) result type.
16pub type Result<T> = std::result::Result<T, FsPathError>;
17
18/// Wrapper for [`File::create`].
19pub fn create_file(path: impl AsRef<Path>) -> Result<fs::File> {
20    let path = path.as_ref();
21    File::create(path).map_err(|err| FsPathError::create_file(err, path))
22}
23
24/// Wrapper for [`std::fs::remove_file`].
25pub fn remove_file(path: impl AsRef<Path>) -> Result<()> {
26    let path = path.as_ref();
27    fs::remove_file(path).map_err(|err| FsPathError::remove_file(err, path))
28}
29
30/// Wrapper for [`std::fs::read`].
31pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
32    let path = path.as_ref();
33    fs::read(path).map_err(|err| FsPathError::read(err, path))
34}
35
36/// Wrapper for [`std::fs::read_link`].
37pub fn read_link(path: impl AsRef<Path>) -> Result<PathBuf> {
38    let path = path.as_ref();
39    fs::read_link(path).map_err(|err| FsPathError::read_link(err, path))
40}
41
42/// Wrapper for [`std::fs::read_to_string`].
43pub fn read_to_string(path: impl AsRef<Path>) -> Result<String> {
44    let path = path.as_ref();
45    fs::read_to_string(path).map_err(|err| FsPathError::read(err, path))
46}
47
48/// Reads the JSON file and deserialize it into the provided type.
49pub fn read_json_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
50    // read the file into a byte array first
51    // https://github.com/serde-rs/json/issues/160
52    let s = read_to_string(path)?;
53    serde_json::from_str(&s).map_err(|source| FsPathError::ReadJson { source, path: path.into() })
54}
55
56/// Reads and decodes the json gzip file, then deserialize it into the provided type.
57pub fn read_json_gzip_file<T: DeserializeOwned>(path: &Path) -> Result<T> {
58    let file = open(path)?;
59    let reader = BufReader::new(file);
60    let decoder = GzDecoder::new(reader);
61    serde_json::from_reader(decoder)
62        .map_err(|source| FsPathError::ReadJson { source, path: path.into() })
63}
64
65/// Reads the entire contents of a locked shared file into a string.
66pub fn locked_read_to_string(path: impl AsRef<Path>) -> Result<String> {
67    let path = path.as_ref();
68    let contents = locked_read(path)?;
69    String::from_utf8(contents).map_err(|err| FsPathError::read(std::io::Error::other(err), path))
70}
71
72/// Reads the entire contents of a locked shared file into a bytes vector.
73pub fn locked_read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
74    let path = path.as_ref();
75    let mut file =
76        fs::OpenOptions::new().read(true).open(path).map_err(|err| FsPathError::open(err, path))?;
77    file.lock_shared().map_err(|err| FsPathError::lock(err, path))?;
78    let contents = read_inner(path, &mut file)?;
79    file.unlock().map_err(|err| FsPathError::unlock(err, path))?;
80    Ok(contents)
81}
82
83fn read_inner(path: &Path, file: &mut File) -> Result<Vec<u8>> {
84    let file_len = file.metadata().map_err(|err| FsPathError::open(err, path))?.len() as usize;
85    let mut buffer = Vec::with_capacity(file_len);
86    file.read_to_end(&mut buffer).map_err(|err| FsPathError::read(err, path))?;
87    Ok(buffer)
88}
89
90/// Writes the object as a JSON object.
91pub fn write_json_file<T: Serialize>(path: &Path, obj: &T) -> Result<()> {
92    let file = create_file(path)?;
93    let mut writer = BufWriter::new(file);
94    serde_json::to_writer(&mut writer, obj)
95        .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?;
96    writer.flush().map_err(|e| FsPathError::write(e, path))
97}
98
99/// Writes the object as a pretty JSON object.
100pub fn write_pretty_json_file<T: Serialize>(path: &Path, obj: &T) -> Result<()> {
101    write_pretty_json(path, obj, create_file(path)?)
102}
103
104/// Writes an object as pretty JSON with owner-only permissions on Unix.
105pub fn write_sensitive_json_file<T: Serialize>(path: &Path, obj: &T) -> Result<()> {
106    let mut options = File::options();
107    // Truncate only after restricting permissions so a failed `chmod` preserves existing contents.
108    options.write(true).create(true).truncate(false);
109    #[cfg(unix)]
110    options.mode(0o600);
111
112    let file = options.open(path).map_err(|err| FsPathError::create_file(err, path))?;
113    #[cfg(unix)]
114    file.set_permissions(fs::Permissions::from_mode(0o600))
115        .map_err(|err| FsPathError::write(err, path))?;
116    file.set_len(0).map_err(|err| FsPathError::write(err, path))?;
117
118    write_pretty_json(path, obj, file)
119}
120
121fn write_pretty_json<T: Serialize>(path: &Path, obj: &T, file: File) -> Result<()> {
122    let mut writer = BufWriter::new(file);
123    serde_json::to_writer_pretty(&mut writer, obj)
124        .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?;
125    writer.flush().map_err(|e| FsPathError::write(e, path))
126}
127
128/// Writes the object as a gzip compressed file.
129pub fn write_json_gzip_file<T: Serialize>(path: &Path, obj: &T) -> Result<()> {
130    let file = create_file(path)?;
131    let writer = BufWriter::new(file);
132    let mut encoder = GzEncoder::new(writer, Compression::default());
133    serde_json::to_writer(&mut encoder, obj)
134        .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?;
135    // Ensure we surface any I/O errors on final gzip write and buffer flush.
136    let mut inner_writer = encoder.finish().map_err(|e| FsPathError::write(e, path))?;
137    inner_writer.flush().map_err(|e| FsPathError::write(e, path))?;
138    Ok(())
139}
140
141/// Wrapper for `std::fs::write`
142pub fn write(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<()> {
143    let path = path.as_ref();
144    fs::write(path, contents).map_err(|err| FsPathError::write(err, path))
145}
146
147/// Writes all content in an exclusive locked file.
148pub fn locked_write(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<()> {
149    let path = path.as_ref();
150    let mut file = fs::OpenOptions::new()
151        .write(true)
152        .create(true)
153        .truncate(false)
154        .open(path)
155        .map_err(|err| FsPathError::open(err, path))?;
156    file.lock().map_err(|err| FsPathError::lock(err, path))?;
157    file.set_len(0).map_err(|err| FsPathError::write(err, path))?;
158    file.write_all(contents.as_ref()).map_err(|err| FsPathError::write(err, path))?;
159    file.unlock().map_err(|err| FsPathError::unlock(err, path))
160}
161
162/// Writes a line in an exclusive locked file.
163pub fn locked_write_line(path: impl AsRef<Path>, line: &str) -> Result<()> {
164    let path = path.as_ref();
165    if cfg!(windows) {
166        return locked_write_line_windows(path, line);
167    }
168
169    let mut file = std::fs::OpenOptions::new()
170        .append(true)
171        .create(true)
172        .open(path)
173        .map_err(|err| FsPathError::open(err, path))?;
174
175    file.lock().map_err(|err| FsPathError::lock(err, path))?;
176    writeln!(file, "{line}").map_err(|err| FsPathError::write(err, path))?;
177    file.unlock().map_err(|err| FsPathError::unlock(err, path))
178}
179
180// Locking fails on Windows if the file is opened in append mode.
181fn locked_write_line_windows(path: &Path, line: &str) -> Result<()> {
182    let mut file = std::fs::OpenOptions::new()
183        .write(true)
184        .truncate(false)
185        .create(true)
186        .open(path)
187        .map_err(|err| FsPathError::open(err, path))?;
188    file.lock().map_err(|err| FsPathError::lock(err, path))?;
189
190    file.seek(SeekFrom::End(0)).map_err(|err| FsPathError::write(err, path))?;
191    writeln!(file, "{line}").map_err(|err| FsPathError::write(err, path))?;
192
193    file.unlock().map_err(|err| FsPathError::unlock(err, path))
194}
195
196/// Wrapper for `std::fs::copy`
197pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
198    let from = from.as_ref();
199    let to = to.as_ref();
200    fs::copy(from, to).map_err(|err| FsPathError::copy(err, from, to))
201}
202
203/// Wrapper for `std::fs::create_dir`
204pub fn create_dir(path: impl AsRef<Path>) -> Result<()> {
205    let path = path.as_ref();
206    fs::create_dir(path).map_err(|err| FsPathError::create_dir(err, path))
207}
208
209/// Wrapper for `std::fs::create_dir_all`
210pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
211    let path = path.as_ref();
212    fs::create_dir_all(path).map_err(|err| FsPathError::create_dir(err, path))
213}
214
215/// Wrapper for `std::fs::remove_dir`
216pub fn remove_dir(path: impl AsRef<Path>) -> Result<()> {
217    let path = path.as_ref();
218    fs::remove_dir(path).map_err(|err| FsPathError::remove_dir(err, path))
219}
220
221/// Wrapper for `std::fs::remove_dir_all`
222pub fn remove_dir_all(path: impl AsRef<Path>) -> Result<()> {
223    let path = path.as_ref();
224    fs::remove_dir_all(path).map_err(|err| FsPathError::remove_dir(err, path))
225}
226
227/// Wrapper for `std::fs::File::open`
228pub fn open(path: impl AsRef<Path>) -> Result<fs::File> {
229    let path = path.as_ref();
230    fs::File::open(path).map_err(|err| FsPathError::open(err, path))
231}
232
233/// Normalize a path, removing things like `.` and `..`.
234///
235/// NOTE: This does not return symlinks and does not touch the filesystem at all (unlike
236/// [`std::fs::canonicalize`])
237///
238/// ref: <https://github.com/rust-lang/cargo/blob/9ded34a558a900563b0acf3730e223c649cf859d/crates/cargo-util/src/paths.rs#L81>
239pub fn normalize_path(path: &Path) -> PathBuf {
240    let mut components = path.components().peekable();
241    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
242        components.next();
243        PathBuf::from(c.as_os_str())
244    } else {
245        PathBuf::new()
246    };
247
248    for component in components {
249        match component {
250            Component::Prefix(..) => unreachable!(),
251            Component::RootDir => {
252                ret.push(component.as_os_str());
253            }
254            Component::CurDir => {}
255            Component::ParentDir => {
256                ret.pop();
257            }
258            Component::Normal(c) => {
259                ret.push(c);
260            }
261        }
262    }
263    ret
264}
265
266/// Returns an iterator over all files with the given extension under the `root` dir.
267pub fn files_with_ext<'a>(root: &Path, ext: &'a str) -> impl Iterator<Item = PathBuf> + 'a {
268    walkdir::WalkDir::new(root)
269        .sort_by_file_name()
270        .into_iter()
271        .filter_map(walkdir::Result::ok)
272        .filter(|e| e.file_type().is_file() && e.path().extension() == Some(ext.as_ref()))
273        .map(walkdir::DirEntry::into_path)
274}
275
276/// Returns an iterator over all JSON files under the `root` dir.
277pub fn json_files(root: &Path) -> impl Iterator<Item = PathBuf> {
278    files_with_ext(root, "json")
279}
280
281/// Canonicalize a path, returning an error if the path does not exist.
282///
283/// Mainly useful to apply canonicalization to paths obtained from project files but still error
284/// properly instead of flattening the errors.
285pub fn canonicalize_path(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
286    dunce::canonicalize(path)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::{sync::mpsc, thread, time::Duration};
293
294    #[cfg(unix)]
295    #[test]
296    fn test_write_sensitive_json_file_permissions() {
297        let dir = tempfile::tempdir().unwrap();
298        for name in ["new", "existing"] {
299            let path = dir.path().join(name);
300            if name == "existing" {
301                fs::write(&path, []).unwrap();
302                fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
303            }
304
305            write_sensitive_json_file(&path, &()).unwrap();
306            assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600);
307        }
308    }
309
310    #[test]
311    fn test_normalize_path() {
312        let p = Path::new("/a/../file.txt");
313        let normalized = normalize_path(p);
314        assert_eq!(normalized, PathBuf::from("/file.txt"));
315    }
316
317    #[test]
318    fn test_locked_write_waits_before_truncating() {
319        let dir = tempfile::tempdir().unwrap();
320        let path = dir.path().join("locked.txt");
321        fs::write(&path, b"original contents").unwrap();
322        let mut reader = File::open(&path).unwrap();
323        reader.lock_shared().unwrap();
324
325        let (started_tx, started_rx) = mpsc::channel();
326        let (finished_tx, finished_rx) = mpsc::channel();
327        let writer_path = path.clone();
328        let writer = thread::spawn(move || {
329            started_tx.send(()).unwrap();
330            let result = locked_write(writer_path, b"new");
331            finished_tx.send(()).unwrap();
332            result
333        });
334        started_rx.recv().unwrap();
335        // Give the writer time to reach the held lock before inspecting the original contents.
336        let pending = finished_rx.recv_timeout(Duration::from_millis(100));
337        let mut contents = Vec::new();
338        let read_result = reader.read_to_end(&mut contents);
339        let unlock_result = reader.unlock();
340        drop(reader);
341        writer.join().unwrap().unwrap();
342
343        unlock_result.unwrap();
344        assert_eq!(pending, Err(mpsc::RecvTimeoutError::Timeout));
345        read_result.unwrap();
346        assert_eq!(contents, b"original contents");
347        assert_eq!(fs::read(&path).unwrap(), b"new");
348    }
349}