Skip to main content

mkt_core/config/
secure_write.rs

1//! Secure config-file writing and permission checks.
2//!
3//! The config file can hold access tokens, so it must be owner-only from
4//! the first byte: creating it and then chmod-ing leaves a window where
5//! the umask decides who can read it.
6
7use std::path::Path;
8
9use crate::error::Result;
10
11/// Write `content` to `path`, creating the file owner-only (0600).
12///
13/// On Unix the mode applies from the first byte and pre-existing files
14/// are tightened to 0600 as well; on other platforms this is a plain
15/// write (Windows relies on ACLs).
16///
17/// # Errors
18///
19/// Returns an error if the file cannot be created or written.
20pub fn write_private(path: &Path, content: &str) -> Result<()> {
21    #[cfg(unix)]
22    {
23        use std::io::Write as _;
24        use std::os::unix::fs::OpenOptionsExt as _;
25        use std::os::unix::fs::PermissionsExt as _;
26
27        let mut file = std::fs::OpenOptions::new()
28            .write(true)
29            .create(true)
30            .truncate(true)
31            .mode(0o600)
32            .open(path)?;
33        file.write_all(content.as_bytes())?;
34        // `mode` only applies on creation; an existing file keeps its old
35        // permissions, so tighten those too.
36        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
37        Ok(())
38    }
39    #[cfg(not(unix))]
40    {
41        std::fs::write(path, content)?;
42        Ok(())
43    }
44}
45
46/// A warning message when `path` is readable or writable by group/other
47/// (the file may contain tokens). `None` when permissions are fine, the
48/// file does not exist, or the platform has no Unix modes.
49#[must_use]
50pub fn permissions_warning(path: &Path) -> Option<String> {
51    #[cfg(unix)]
52    {
53        use std::os::unix::fs::PermissionsExt as _;
54        let mode = std::fs::metadata(path).ok()?.permissions().mode();
55        if mode & 0o077 != 0 {
56            return Some(format!(
57                "{} is accessible by other users (mode {:03o}); run: chmod 600 {}",
58                path.display(),
59                mode & 0o777,
60                path.display()
61            ));
62        }
63        None
64    }
65    #[cfg(not(unix))]
66    {
67        let _ = path;
68        None
69    }
70}
71
72#[cfg(all(test, unix))]
73mod tests {
74    #![allow(clippy::unwrap_used, clippy::expect_used)]
75
76    use std::os::unix::fs::PermissionsExt as _;
77
78    use super::*;
79
80    fn temp_path() -> std::path::PathBuf {
81        std::env::temp_dir().join(format!("mkt-secure-write-{}", uuid::Uuid::new_v4()))
82    }
83
84    #[test]
85    fn new_file_is_created_owner_only() {
86        let path = temp_path();
87        write_private(&path, "secret = true").unwrap();
88        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
89        std::fs::remove_file(&path).unwrap();
90        assert_eq!(mode, 0o600, "new config files must be 0600, got {mode:03o}");
91    }
92
93    #[test]
94    fn existing_loose_file_is_tightened() {
95        let path = temp_path();
96        std::fs::write(&path, "old").unwrap();
97        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
98
99        write_private(&path, "new").unwrap();
100        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
101        let content = std::fs::read_to_string(&path).unwrap();
102        std::fs::remove_file(&path).unwrap();
103        assert_eq!(mode, 0o600);
104        assert_eq!(content, "new");
105    }
106
107    #[test]
108    fn warning_fires_only_for_loose_permissions() {
109        let path = temp_path();
110        write_private(&path, "x").unwrap();
111        assert!(permissions_warning(&path).is_none());
112
113        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
114        let warning = permissions_warning(&path);
115        std::fs::remove_file(&path).unwrap();
116        let warning = warning.expect("0644 must warn");
117        assert!(warning.contains("chmod 600"), "{warning}");
118        assert!(warning.contains("644"), "{warning}");
119    }
120
121    #[test]
122    fn missing_file_does_not_warn() {
123        assert!(permissions_warning(Path::new("/nonexistent/mkt-test")).is_none());
124    }
125}