Skip to main content

battlecommand_forge/
secrets.rs

1//! Atomic write helper for files holding sensitive content.
2//!
3//! `write_secret_file` writes via a temp file + rename so partial writes
4//! are never observable, and on Unix opens the temp file with mode 0600
5//! so the bytes are never world-readable even between create and rename.
6
7use std::fs::OpenOptions;
8use std::io::{self, Write};
9use std::path::Path;
10
11#[cfg(unix)]
12use std::os::unix::fs::OpenOptionsExt;
13
14/// Atomically write `contents` to `path`. Creates parent dirs as needed.
15/// On Unix the temp file is opened with mode 0o600.
16pub fn write_secret_file(path: &Path, contents: &[u8]) -> io::Result<()> {
17    if let Some(parent) = path.parent() {
18        if !parent.as_os_str().is_empty() {
19            std::fs::create_dir_all(parent)?;
20        }
21    }
22
23    let tmp_path = match path.file_name() {
24        Some(name) => {
25            let mut tmp_name = name.to_os_string();
26            tmp_name.push(".tmp");
27            path.with_file_name(tmp_name)
28        }
29        None => {
30            return Err(io::Error::new(
31                io::ErrorKind::InvalidInput,
32                "write_secret_file: path has no file name",
33            ));
34        }
35    };
36
37    {
38        let mut opts = OpenOptions::new();
39        opts.write(true).create(true).truncate(true);
40        #[cfg(unix)]
41        opts.mode(0o600);
42        let mut f = opts.open(&tmp_path)?;
43        f.write_all(contents)?;
44        f.sync_all()?;
45    }
46
47    std::fs::rename(&tmp_path, path)?;
48    Ok(())
49}
50
51/// Ensure a sensitive append-only file exists with mode 0600 set at
52/// creation time. Subsequent appends inherit the mode. No-op if the
53/// file already exists.
54pub fn ensure_secret_file(path: &Path) -> io::Result<()> {
55    if path.exists() {
56        return Ok(());
57    }
58    write_secret_file(path, b"")
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use std::io::Read;
65
66    #[test]
67    fn test_write_secret_file_roundtrip() {
68        let dir = std::env::temp_dir().join(format!("bcf-secrets-{}", std::process::id()));
69        let path = dir.join("token");
70        write_secret_file(&path, b"hunter2").unwrap();
71
72        let mut s = String::new();
73        std::fs::File::open(&path)
74            .unwrap()
75            .read_to_string(&mut s)
76            .unwrap();
77        assert_eq!(s, "hunter2");
78
79        std::fs::remove_dir_all(&dir).ok();
80    }
81
82    #[test]
83    fn test_write_secret_file_overwrites() {
84        let dir = std::env::temp_dir().join(format!("bcf-secrets2-{}", std::process::id()));
85        let path = dir.join("data");
86        write_secret_file(&path, b"first").unwrap();
87        write_secret_file(&path, b"second").unwrap();
88
89        let s = std::fs::read_to_string(&path).unwrap();
90        assert_eq!(s, "second");
91
92        std::fs::remove_dir_all(&dir).ok();
93    }
94
95    #[cfg(unix)]
96    #[test]
97    fn test_write_secret_file_mode_0600() {
98        use std::os::unix::fs::PermissionsExt;
99        let dir = std::env::temp_dir().join(format!("bcf-secrets3-{}", std::process::id()));
100        let path = dir.join("locked");
101        write_secret_file(&path, b"x").unwrap();
102
103        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
104        assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
105
106        std::fs::remove_dir_all(&dir).ok();
107    }
108}