Skip to main content

conmonrs/
fs_util.rs

1//! File system helpers.
2
3use crate::random_id::RandomId;
4use std::{
5    env,
6    fs::{self, Metadata},
7    io::{self, ErrorKind},
8    os::unix::fs::MetadataExt,
9    path::{Path, PathBuf},
10};
11use tracing::debug;
12
13/// Remove a file, one which does not exist is no error.
14pub(crate) fn remove_file(path: &Path) -> io::Result<()> {
15    match fs::remove_file(path) {
16        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
17        result => result,
18    }
19}
20
21/// A file path which gets removed on drop.
22#[derive(Debug)]
23pub(crate) struct TempFile(PathBuf);
24
25impl TempFile {
26    /// A unique path in the `directory`, or in the one for temporary files
27    /// without one, whose file does not get created but removed on drop.
28    /// This covers early returns and cancelled futures, which would leave
29    /// for example the PID file of an exec behind.
30    pub(crate) fn new(directory: Option<&Path>, prefix: &str, suffix: &str) -> Self {
31        let dir = directory.map_or_else(env::temp_dir, Path::to_path_buf);
32        let unique = RandomId::new();
33        Self(dir.join(format!("{prefix}{unique}{suffix}")))
34    }
35
36    /// The path of the file.
37    pub(crate) fn path(&self) -> &Path {
38        &self.0
39    }
40}
41
42impl Drop for TempFile {
43    fn drop(&mut self) {
44        // A plain unlink does not need the runtime and cannot panic without one.
45        // The file may be gone already: the terminal listener removes its
46        // socket once it got the descriptor, and a runtime may never write a
47        // pidfile.
48        if let Err(e) = remove_file(&self.0) {
49            debug!("Unable to remove temp file {}: {}", self.0.display(), e);
50        }
51    }
52}
53
54/// The identity of a file, which tells whether a path still refers to it.
55/// Its inode number cannot be reused while the file is open, like a socket
56/// the server still listens on.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub(crate) struct FileId {
59    dev: u64,
60    ino: u64,
61}
62
63impl FileId {
64    /// The identity of the file of `metadata`.
65    pub(crate) fn new(metadata: &Metadata) -> Self {
66        Self {
67            dev: metadata.dev(),
68            ino: metadata.ino(),
69        }
70    }
71
72    /// Whether the path still refers to the file, without following a
73    /// symlink.
74    pub(crate) fn is_at(self, path: &Path) -> bool {
75        fs::symlink_metadata(path).is_ok_and(|m| Self::new(&m) == self)
76    }
77
78    /// Remove the path if it still refers to the file. Another file at the
79    /// path stays, a missing one counts as removed.
80    pub(crate) fn remove_at(self, path: &Path) -> Result<(), RemoveError> {
81        match fs::symlink_metadata(path) {
82            Ok(m) if Self::new(&m) == self => fs::remove_file(path).map_err(RemoveError::Remove),
83            Ok(_) => Ok(()),
84            Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
85            Err(e) => Err(RemoveError::Stat(e)),
86        }
87    }
88}
89
90/// Why [`FileId::remove_at`] failed.
91#[derive(Debug)]
92pub(crate) enum RemoveError {
93    /// The path could not be checked.
94    Stat(io::Error),
95
96    /// The file could not be removed.
97    Remove(io::Error),
98}
99
100/// Fail for a path of a request which is not absolute, an empty one
101/// included. The server would resolve it in its working directory otherwise.
102pub(crate) fn check_absolute(what: &str, path: &Path) -> anyhow::Result<()> {
103    if !path.is_absolute() {
104        anyhow::bail!("{what} {:?} is not absolute", path.display())
105    }
106    Ok(())
107}
108
109/// Fail for a path of a request which cannot be a file the server writes:
110/// one which is not absolute, see [`check_absolute`], or one which does not
111/// name a file, like `/` or one which ends in `..`, `/` or `/.`. Writing it
112/// would fail anyway, but only once the container exited, when nobody gets
113/// told anymore. [`Path::file_name`] alone would take the last two for the
114/// file before them.
115pub(crate) fn check_file_path(what: &str, path: &Path) -> anyhow::Result<()> {
116    use std::os::unix::ffi::OsStrExt as _;
117
118    check_absolute(what, path)?;
119    let bytes = path.as_os_str().as_bytes();
120    if path.file_name().is_none() || bytes.ends_with(b"/") || bytes.ends_with(b"/.") {
121        anyhow::bail!("{what} {:?} does not name a file", path.display())
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn missing_files_count_as_removed() -> io::Result<()> {
132        let dir = tempfile::tempdir()?;
133        let path = dir.path().join("file");
134        remove_file(&path)?;
135        fs::write(&path, "")?;
136        remove_file(&path)?;
137        assert!(!path.exists());
138        assert!(remove_file(dir.path()).is_err());
139        Ok(())
140    }
141
142    #[test]
143    fn temp_files_get_removed_on_drop() -> io::Result<()> {
144        let dir = tempfile::tempdir()?;
145        let file = TempFile::new(Some(dir.path()), "prefix-", ".suffix");
146        let path = file.path().to_path_buf();
147        let name = path.file_name().and_then(|name| name.to_str());
148        assert!(name.is_some_and(|n| n.starts_with("prefix-") && n.ends_with(".suffix")));
149        assert_eq!(path.parent(), Some(dir.path()));
150        assert_ne!(
151            TempFile::new(Some(dir.path()), "prefix-", ".suffix").path(),
152            path
153        );
154
155        // Only the path, the file is up to the user.
156        assert!(!path.exists());
157        fs::write(&path, "")?;
158        drop(file);
159        assert!(!path.exists());
160
161        // One which never got created, in the directory for temporary files.
162        let file = TempFile::new(None, "prefix-", ".suffix");
163        assert_eq!(file.path().parent(), Some(env::temp_dir().as_path()));
164        drop(file);
165        Ok(())
166    }
167
168    #[test]
169    fn only_the_same_file_gets_removed() -> io::Result<()> {
170        let dir = tempfile::tempdir()?;
171        let path = dir.path().join("file");
172        fs::write(&path, "")?;
173        let id = FileId::new(&fs::metadata(&path)?);
174        assert!(id.is_at(&path));
175        // A symlink to the file is not the file.
176        let link = dir.path().join("link");
177        std::os::unix::fs::symlink(&path, &link)?;
178        assert!(!id.is_at(&link));
179
180        // Replaced by another file, which stays.
181        let other = dir.path().join("other");
182        fs::write(&other, "")?;
183        let _held = fs::File::open(&path)?;
184        fs::rename(&other, &path)?;
185        assert!(!id.is_at(&path));
186        id.remove_at(&path).expect("another file stays");
187        assert!(path.exists());
188
189        let id = FileId::new(&fs::metadata(&path)?);
190        id.remove_at(&path).expect("remove the file");
191        assert!(!path.exists());
192        id.remove_at(&path)
193            .expect("a missing file counts as removed");
194
195        // Only the file itself gets stat failures reported.
196        let under_file = path.join("file");
197        fs::write(&path, "")?;
198        let id = FileId::new(&fs::metadata(&path)?);
199        assert!(matches!(
200            id.remove_at(&under_file),
201            Err(RemoveError::Stat(_))
202        ));
203        Ok(())
204    }
205
206    #[test]
207    fn paths_have_to_be_absolute() {
208        assert!(check_absolute("bundle path", Path::new("/bundle")).is_ok());
209        for path in ["", "bundle", "./bundle"] {
210            let err = check_absolute("bundle path", Path::new(path)).unwrap_err();
211            assert!(err.to_string().contains("is not absolute"), "{err}");
212        }
213    }
214
215    #[test]
216    fn file_paths_have_to_name_a_file() {
217        for path in [
218            "/exit",
219            "/dir/exit",
220            "/dir/./exit",
221            "/dir/.exit",
222            "/dir/exit.",
223        ] {
224            assert!(check_file_path("exit path", Path::new(path)).is_ok());
225        }
226        // The last two would fail the rename onto them at the exit.
227        for path in ["/", "/dir/..", "/..", "/dir/exit/", "/dir/exit/."] {
228            let err = check_file_path("exit path", Path::new(path)).unwrap_err();
229            assert_eq!(
230                err.to_string(),
231                format!("exit path {path:?} does not name a file")
232            );
233        }
234        let err = check_file_path("exit path", Path::new("exit")).unwrap_err();
235        assert!(err.to_string().contains("is not absolute"), "{err}");
236    }
237}