Skip to main content

harmont_cli/
fs_util.rs

1//! Small filesystem helpers for atomic, permission-restricted writes.
2//!
3//! The main entry point is [`write_atomic_restricted`]. It is used by
4//! [`crate::creds_store`] (file-backed credential store) and by
5//! `Config::save`, both of which write into `~/.harmont/`
6//! (see `config::user_config_dir`).
7
8use anyhow::{Context, Result};
9use std::path::Path;
10
11/// Write `contents` to `path` atomically with `file_mode`, ensuring the
12/// parent directory exists and is set to `dir_mode`.
13///
14/// On Unix the target file is created with `OpenOptions::mode(file_mode)`
15/// before any bytes are written, closing the TOCTOU window that
16/// `fs::write(…)` + `set_permissions(…)` opens. The parent directory is
17/// created with `DirBuilder::mode(dir_mode)`; if the directory already
18/// exists with a looser mode, it is tightened.
19///
20/// On non-Unix platforms the mode arguments are ignored and the function
21/// falls back to `std::fs::create_dir_all` + tempfile + rename.
22///
23/// Atomicity: contents are written to a sibling tempfile and then
24/// `rename`d over `path`, so readers always observe either the full old
25/// contents or the full new contents — never a truncated file.
26///
27/// # Errors
28///
29/// Returns an error if `path` has no parent or no file-name component,
30/// the parent directory cannot be created or chmod'd to `dir_mode`, the
31/// tempfile cannot be opened with `file_mode` or written, or the final
32/// `rename` over `path` fails. The tempfile is cleaned up on rename
33/// failure so secret material doesn't linger.
34pub fn write_atomic_restricted(
35    path: &Path,
36    contents: &[u8],
37    file_mode: u32,
38    dir_mode: u32,
39) -> Result<()> {
40    let parent = path
41        .parent()
42        .with_context(|| format!("{} has no parent directory", path.display()))?;
43
44    create_dir_with_mode(parent, dir_mode)
45        .with_context(|| format!("creating {}", parent.display()))?;
46
47    // Tempfile name is unique per process (pid) and per target filename,
48    // which is sufficient because write_atomic_restricted is never called
49    // concurrently on the same target from within a single process.
50    let file_name = path
51        .file_name()
52        .with_context(|| format!("{} has no file name", path.display()))?
53        .to_os_string();
54    let mut tmp_name = file_name;
55    tmp_name.push(format!(".tmp.{}", std::process::id()));
56    let tmp_path = parent.join(&tmp_name);
57
58    write_file_with_mode(&tmp_path, contents, file_mode)
59        .with_context(|| format!("writing {}", tmp_path.display()))?;
60
61    let persist_result = std::fs::rename(&tmp_path, path)
62        .with_context(|| format!("renaming {} -> {}", tmp_path.display(), path.display()));
63
64    if persist_result.is_err() {
65        // Clean up the orphaned tempfile — it may contain secret material
66        // and we don't want it sitting at an unexpected path.
67        let _ = std::fs::remove_file(&tmp_path);
68    }
69    persist_result?;
70
71    Ok(())
72}
73
74/// Remove a file if it exists; silently return `Ok(())` if it does not.
75///
76/// # Errors
77///
78/// Returns an error if `remove_file` fails for any reason other than
79/// `NotFound` (typically permission denied or the path being a
80/// non-empty directory).
81pub fn remove_if_exists(path: &Path) -> Result<()> {
82    match std::fs::remove_file(path) {
83        Ok(()) => Ok(()),
84        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
85        Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
86    }
87}
88
89#[cfg(unix)]
90fn create_dir_with_mode(dir: &Path, mode: u32) -> std::io::Result<()> {
91    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
92    if dir.exists() {
93        let current = std::fs::metadata(dir)?.permissions().mode() & 0o777;
94        if current != mode {
95            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode))?;
96        }
97    } else {
98        std::fs::DirBuilder::new()
99            .recursive(true)
100            .mode(mode)
101            .create(dir)?;
102    }
103    Ok(())
104}
105
106#[cfg(not(unix))]
107fn create_dir_with_mode(dir: &Path, _mode: u32) -> std::io::Result<()> {
108    std::fs::create_dir_all(dir)
109}
110
111#[cfg(unix)]
112fn write_file_with_mode(path: &Path, contents: &[u8], mode: u32) -> std::io::Result<()> {
113    use std::io::Write;
114    use std::os::unix::fs::OpenOptionsExt;
115    let mut f = std::fs::OpenOptions::new()
116        .write(true)
117        .create(true)
118        .truncate(true)
119        .mode(mode)
120        .open(path)?;
121    f.write_all(contents)?;
122    f.sync_all()?;
123    Ok(())
124}
125
126#[cfg(not(unix))]
127fn write_file_with_mode(path: &Path, contents: &[u8], _mode: u32) -> std::io::Result<()> {
128    std::fs::write(path, contents)
129}
130
131#[cfg(all(test, unix))]
132#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
133mod tests {
134    use super::*;
135    use std::os::unix::fs::PermissionsExt;
136
137    #[test]
138    fn writes_file_and_dir_with_requested_modes() {
139        let tmp = tempfile::tempdir().unwrap();
140        let target = tmp.path().join("sub").join("creds");
141        write_atomic_restricted(&target, b"hello", 0o600, 0o700).unwrap();
142
143        assert_eq!(std::fs::read(&target).unwrap(), b"hello");
144        let file_mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
145        let dir_mode = std::fs::metadata(target.parent().unwrap())
146            .unwrap()
147            .permissions()
148            .mode()
149            & 0o777;
150        assert_eq!(
151            file_mode, 0o600,
152            "file mode must be 0o600, got {file_mode:o}"
153        );
154        assert_eq!(dir_mode, 0o700, "dir mode must be 0o700, got {dir_mode:o}");
155    }
156
157    #[test]
158    fn overwrites_existing_file_preserving_mode() {
159        let tmp = tempfile::tempdir().unwrap();
160        let target = tmp.path().join("creds");
161        write_atomic_restricted(&target, b"v1", 0o600, 0o700).unwrap();
162        write_atomic_restricted(&target, b"v2", 0o600, 0o700).unwrap();
163
164        assert_eq!(std::fs::read(&target).unwrap(), b"v2");
165        let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
166        assert_eq!(mode, 0o600);
167    }
168
169    #[test]
170    fn tightens_existing_dir_with_looser_mode() {
171        let tmp = tempfile::tempdir().unwrap();
172        let dir = tmp.path().join("loose");
173        std::fs::create_dir(&dir).unwrap();
174        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
175
176        let target = dir.join("creds");
177        write_atomic_restricted(&target, b"x", 0o600, 0o700).unwrap();
178
179        let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
180        assert_eq!(dir_mode, 0o700);
181    }
182
183    #[test]
184    fn remove_if_exists_is_idempotent() {
185        let tmp = tempfile::tempdir().unwrap();
186        let target = tmp.path().join("nothing");
187        remove_if_exists(&target).unwrap();
188        std::fs::write(&target, "x").unwrap();
189        remove_if_exists(&target).unwrap();
190        assert!(!target.exists());
191    }
192}