Skip to main content

didbot_config/
secret.rs

1//! Permission checking for the files this project's secrets live in.
2//!
3//! The shape a secret takes here: it is
4//! named, on disk, and read once at startup, so process
5//! arguments never carry it. That is only as good as the file's own
6//! permissions — a secret file the whole host can read has the same problem
7//! a process argument would leak, one layer down. This does not refuse a
8//! loose file; it complains, loudly enough that an operator notices before
9//! someone else reads it.
10
11use std::path::{Path, PathBuf};
12
13/// Bits that must not be set on a file holding a secret: group and other,
14/// every permission. `0600` is the only mode that passes.
15const LOOSE_MASK: u32 = 0o077;
16
17/// A secret file whose permissions are wider than its owner.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PermissionWarning {
20    /// The file that was checked.
21    pub path: PathBuf,
22    /// The mode found, masked to the permission bits.
23    pub mode: u32,
24}
25
26impl std::fmt::Display for PermissionWarning {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(
29            f,
30            "{} is mode {:04o}, readable beyond its owner; a secret file should be 0600 (chmod 600 {})",
31            self.path.display(),
32            self.mode,
33            self.path.display()
34        )
35    }
36}
37
38/// Checks a secret file's permissions, on Unix.
39///
40/// `Ok(None)` means the file is `0600` or tighter. `Ok(Some(_))` names what
41/// was found. `Err` is a filesystem problem reading the metadata at all —
42/// distinct from a loose permission, and left for the caller to decide
43/// whether a missing file is itself fatal.
44#[cfg(unix)]
45pub fn check_secret_permissions(path: &Path) -> std::io::Result<Option<PermissionWarning>> {
46    use std::os::unix::fs::PermissionsExt;
47    let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
48    Ok((mode & LOOSE_MASK != 0).then(|| PermissionWarning {
49        path: path.to_owned(),
50        mode,
51    }))
52}
53
54/// Off Unix there is no portable permission bit to check; nothing is
55/// reported rather than something invented.
56#[cfg(not(unix))]
57pub fn check_secret_permissions(_path: &Path) -> std::io::Result<Option<PermissionWarning>> {
58    Ok(None)
59}
60
61#[cfg(all(test, unix))]
62mod tests {
63    use super::*;
64    use std::fs;
65    use std::os::unix::fs::PermissionsExt;
66
67    fn write_with_mode(dir: &Path, mode: u32) -> PathBuf {
68        let path = dir.join("secret");
69        fs::write(&path, b"shh").unwrap();
70        fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap();
71        path
72    }
73
74    #[test]
75    fn a_tight_file_is_not_reported() {
76        let dir = tempdir();
77        let path = write_with_mode(dir.path(), 0o600);
78        assert_eq!(check_secret_permissions(&path).unwrap(), None);
79    }
80
81    #[test]
82    fn group_readable_is_reported() {
83        let dir = tempdir();
84        let path = write_with_mode(dir.path(), 0o640);
85        let warning = check_secret_permissions(&path).unwrap().unwrap();
86        assert_eq!(warning.mode, 0o640);
87        assert!(warning.to_string().contains("chmod 600"));
88    }
89
90    #[test]
91    fn world_readable_is_reported() {
92        let dir = tempdir();
93        let path = write_with_mode(dir.path(), 0o644);
94        assert!(check_secret_permissions(&path).unwrap().is_some());
95    }
96
97    #[test]
98    fn a_missing_file_is_an_io_error_not_a_warning() {
99        let dir = tempdir();
100        let path = dir.path().join("nope");
101        assert!(check_secret_permissions(&path).is_err());
102    }
103
104    /// A bare `mkdtemp`-alike, with no dependency on a `tempfile` crate this
105    /// workspace does not otherwise carry: cleaned up on drop.
106    struct TempDir(PathBuf);
107    impl TempDir {
108        fn path(&self) -> &Path {
109            &self.0
110        }
111    }
112    impl Drop for TempDir {
113        fn drop(&mut self) {
114            let _ = fs::remove_dir_all(&self.0);
115        }
116    }
117    fn tempdir() -> TempDir {
118        let dir = std::env::temp_dir().join(format!(
119            "didbot-config-test-{}-{}",
120            std::process::id(),
121            std::time::SystemTime::now()
122                .duration_since(std::time::UNIX_EPOCH)
123                .unwrap()
124                .as_nanos()
125        ));
126        fs::create_dir_all(&dir).unwrap();
127        TempDir(dir)
128    }
129}