Skip to main content

hm_util/os/
fs.rs

1//! Filesystem helpers.
2//!
3//! The main entry point is [`write_atomic_restricted`]. A synchronous
4//! wrapper is available at [`blocking::write_atomic_restricted`] for
5//! callers that run inside a tokio runtime but cannot use async
6//! (e.g. extism `host_fn` callbacks).
7//!
8//! Both guarantee that readers observe either the full old contents or
9//! the full new contents — never a truncated file — and that Unix
10//! file/directory modes are set atomically with creation.
11
12use std::io;
13use std::path::Path;
14
15/// Unix mode bits for a file (e.g. `0o600`).
16///
17/// A distinct newtype from [`DirMode`] so the file- and directory-mode
18/// arguments of [`write_atomic_restricted`] cannot be transposed: passing
19/// them in the wrong order is a compile error rather than a silent
20/// security regression (a secrets file landing at `0o700`, say).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct FileMode(pub u32);
23
24/// Unix mode bits for a directory (e.g. `0o700`).
25///
26/// See [`FileMode`] for why this is a distinct newtype.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct DirMode(pub u32);
29
30/// Write `contents` to `path` atomically with `file`, ensuring the
31/// parent directory exists and is set to `dir`.
32///
33/// # Errors
34///
35/// Returns an error if `path` has no parent or no file-name component,
36/// the parent directory cannot be created or chmod'd to `dir`, the
37/// tempfile cannot be opened with `file` or written, or the final
38/// `rename` over `path` fails.
39pub async fn write_atomic_restricted(
40    path: impl AsRef<Path>,
41    contents: impl AsRef<[u8]>,
42    file: FileMode,
43    dir: DirMode,
44) -> io::Result<()> {
45    let path = path.as_ref().to_owned();
46    let contents = contents.as_ref().to_vec();
47
48    let parent = path
49        .parent()
50        .ok_or_else(|| {
51            io::Error::new(
52                io::ErrorKind::InvalidInput,
53                format!("{} has no parent directory", path.display()),
54            )
55        })?
56        .to_owned();
57
58    create_dir_with_mode(&parent, dir.0).await?;
59
60    let file_name = path
61        .file_name()
62        .ok_or_else(|| {
63            io::Error::new(
64                io::ErrorKind::InvalidInput,
65                format!("{} has no file name", path.display()),
66            )
67        })?
68        .to_os_string();
69    let mut tmp_name = file_name;
70    tmp_name.push(format!(".tmp.{}", std::process::id()));
71    let tmp_path = parent.join(&tmp_name);
72
73    write_file_with_mode(&tmp_path, &contents, file.0).await?;
74
75    let rename_result = atomic_rename_over(&tmp_path, &path).await;
76    if rename_result.is_err() {
77        let _ = tokio::fs::remove_file(&tmp_path).await;
78    }
79    rename_result
80}
81
82/// Atomically replace `to` with `from`.
83///
84/// On Unix this delegates to [`tokio::fs::rename`] (`rename(2)` — atomic
85/// by POSIX guarantee). On Windows this uses `ReplaceFileW` (preserves
86/// ACLs and alternate data streams) when the target exists, falling back
87/// to `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` for first-write.
88///
89/// # Errors
90///
91/// Returns an error if the rename fails (permission denied, cross-device,
92/// source missing, etc.).
93pub async fn atomic_rename_over(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
94    #[cfg(unix)]
95    {
96        tokio::fs::rename(from.as_ref(), to.as_ref()).await
97    }
98    #[cfg(windows)]
99    {
100        fn atomic_rename_over_impl(from: &Path, to: &Path) -> io::Result<()> {
101            use windows::Win32::Storage::FileSystem::{
102                MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
103                REPLACEFILE_IGNORE_MERGE_ERRORS, ReplaceFileW,
104            };
105            use windows::core::HSTRING;
106
107            let from_w = HSTRING::from(from.as_os_str());
108            let to_w = HSTRING::from(to.as_os_str());
109
110            if to.exists() {
111                let result = unsafe {
112                    ReplaceFileW(
113                        &to_w,
114                        &from_w,
115                        windows::core::PCWSTR::null(),
116                        REPLACEFILE_IGNORE_MERGE_ERRORS,
117                        None,
118                        None,
119                    )
120                };
121                return result.map_err(|e| io::Error::new(io::ErrorKind::Other, e));
122            }
123
124            let result = unsafe {
125                MoveFileExW(
126                    &from_w,
127                    &to_w,
128                    MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
129                )
130            };
131            result.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
132        }
133
134        let from = from.as_ref().to_owned();
135        let to = to.as_ref().to_owned();
136        tokio::task::spawn_blocking(move || atomic_rename_over_impl(&from, &to))
137            .await
138            .map_err(io::Error::other)?
139    }
140}
141
142/// Remove a file if it exists; silently return `Ok(())` if it does not.
143///
144/// # Errors
145///
146/// Returns an error if `remove_file` fails for any reason other than
147/// `NotFound`.
148pub async fn remove_file_if_exists(path: impl AsRef<Path>) -> io::Result<()> {
149    match tokio::fs::remove_file(path).await {
150        Ok(()) => Ok(()),
151        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
152        Err(e) => Err(e),
153    }
154}
155
156#[cfg(unix)]
157async fn create_dir_with_mode(dir: &Path, mode: u32) -> io::Result<()> {
158    #[cfg(unix)]
159    {
160        use std::os::unix::fs::PermissionsExt;
161        match tokio::fs::metadata(dir).await {
162            Ok(meta) => {
163                let current = meta.permissions().mode() & 0o777;
164                if current != mode {
165                    tokio::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).await?;
166                }
167            }
168            Err(e) if e.kind() == io::ErrorKind::NotFound => {
169                let mut builder = tokio::fs::DirBuilder::new();
170                builder.recursive(true).mode(mode);
171                builder.create(dir).await?;
172            }
173            Err(e) => return Err(e),
174        }
175    }
176
177    #[cfg(windows)]
178    {
179        tokio::fs::create_dir_all(dir).await
180    }
181    Ok(())
182}
183
184async fn write_file_with_mode(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> {
185    #[cfg(unix)]
186    {
187        use tokio::io::AsyncWriteExt;
188        let mut opts = tokio::fs::OpenOptions::new();
189        opts.write(true).create(true).truncate(true).mode(mode);
190        let mut f = opts.open(path).await?;
191        f.write_all(contents).await?;
192        f.sync_all().await?;
193    }
194
195    #[cfg(windows)]
196    {
197        tokio::fs::write(path, contents).await
198    }
199
200    Ok(())
201}
202
203/// Synchronous wrappers that shell out to the async API via
204/// `tokio::task::block_in_place`. Safe to call from sync contexts
205/// that run inside a tokio runtime (e.g. extism `host_fn` callbacks).
206pub mod blocking {
207    use super::{DirMode, FileMode};
208    use std::io;
209    use std::path::Path;
210
211    fn block_on<F: std::future::Future<Output = io::Result<()>>>(f: F) -> io::Result<()> {
212        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f))
213    }
214
215    /// Blocking counterpart of [`super::write_atomic_restricted`].
216    ///
217    /// See the [module-level documentation](super) for semantics.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if `path` has no parent or no file-name component,
222    /// the parent directory cannot be created or chmod'd to `dir`, the
223    /// tempfile cannot be opened with `file` or written, or the final
224    /// `rename` over `path` fails.
225    pub fn write_atomic_restricted(
226        path: impl AsRef<Path>,
227        contents: impl AsRef<[u8]>,
228        file: FileMode,
229        dir: DirMode,
230    ) -> io::Result<()> {
231        block_on(super::write_atomic_restricted(path, contents, file, dir))
232    }
233
234    /// Blocking counterpart of [`super::remove_file_if_exists`].
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if `remove_file` fails for any reason other than
239    /// `NotFound`.
240    pub fn remove_if_exists(path: impl AsRef<Path>) -> io::Result<()> {
241        block_on(super::remove_file_if_exists(path))
242    }
243}
244
245#[cfg(all(test, unix))]
246#[allow(clippy::unwrap_used)]
247mod tests {
248    use super::*;
249    use std::os::unix::fs::PermissionsExt;
250
251    /// Credentials (and any secret) must land at exactly 0o600, in a 0o700 dir.
252    #[tokio::test]
253    async fn writes_file_0600_in_dir_0700() {
254        let tmp = tempfile::tempdir().unwrap();
255        let dir = tmp.path().join("hm");
256        let file = dir.join("credentials.toml");
257
258        write_atomic_restricted(
259            &file,
260            b"token = \"hunter2\"\n",
261            FileMode(0o600),
262            DirMode(0o700),
263        )
264        .await
265        .unwrap();
266
267        let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
268        assert_eq!(fmode, 0o600, "file mode must be 0o600, got {fmode:o}");
269        let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
270        assert_eq!(dmode, 0o700, "dir mode must be 0o700, got {dmode:o}");
271    }
272
273    /// Overwriting an existing secret must preserve 0o600 (guards the
274    /// temp-file + atomic-rename path against perm drift).
275    #[tokio::test]
276    async fn rewrite_preserves_0600() {
277        let tmp = tempfile::tempdir().unwrap();
278        let file = tmp.path().join("credentials.toml");
279        write_atomic_restricted(&file, b"a", FileMode(0o600), DirMode(0o700))
280            .await
281            .unwrap();
282        write_atomic_restricted(&file, b"bb", FileMode(0o600), DirMode(0o700))
283            .await
284            .unwrap();
285        let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
286        assert_eq!(fmode, 0o600, "file mode must stay 0o600, got {fmode:o}");
287    }
288}