Skip to main content

leviath_sys/
perms.rs

1//! File and directory permission hardening.
2//!
3//! On Unix these set POSIX mode bits; on other platforms they are no-ops that
4//! succeed (Windows ACLs are not modeled here).
5
6use std::io;
7use std::path::Path;
8
9/// Restrict a file to owner-only read/write (`0o600` on Unix; no-op elsewhere).
10pub fn secure_file_perms(path: &Path) -> io::Result<()> {
11    crate::platform::set_mode(path, 0o600)
12}
13
14/// Restrict a directory to owner-only read/write/execute (`0o700` on Unix; no-op elsewhere).
15pub fn secure_dir_perms(path: &Path) -> io::Result<()> {
16    crate::platform::set_mode(path, 0o700)
17}
18
19/// If `path` exists and is accessible to group or others, tighten it to
20/// owner-only (`0o600`) and return `Ok(Some(previous_mode))`. If it is already
21/// private, or does not exist, return `Ok(None)`. On non-Unix platforms this
22/// always returns `Ok(None)`.
23pub fn ensure_file_private(path: &Path) -> io::Result<Option<u32>> {
24    crate::platform::ensure_private(path, 0o600)
25}
26
27/// Write `contents` to `path` such that it is **never** readable by anyone but
28/// the owner, not even briefly.
29///
30/// `fs::write` followed by a `chmod` is the obvious shape and has a window: the
31/// file is created at `0o666 & ~umask` - typically `0o644` - and is
32/// world-readable until the `chmod` lands. Both files that hold Leviath's
33/// secrets were written that way, so `config.toml` (every provider API key) and
34/// `mcp-auth.json` (OAuth access and refresh tokens) each had a moment of being
35/// readable by any local user, on every save.
36///
37/// Creating the file with the mode already set closes that. On non-Unix this is
38/// a plain write - the mode argument has no meaning there, and Windows ACL
39/// handling is a separate piece of work rather than something to fake here.
40pub fn write_private(path: &Path, contents: &[u8]) -> io::Result<()> {
41    crate::platform::write_with_mode(path, contents, 0o600)
42}
43
44// Cross-platform tests: they run on every OS so the public API (and, on
45// non-Unix, the `fallback` no-op impls) is covered everywhere. Only the
46// Unix-specific *assertions* about concrete mode bits are gated behind
47// `#[cfg(unix)]`; on non-Unix the same public calls exercise the no-op
48// fallback, which succeeds and leaves permissions untouched.
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[cfg(unix)]
54    fn mode_of(path: &Path) -> u32 {
55        use std::os::unix::fs::PermissionsExt;
56        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
57    }
58
59    #[cfg(unix)]
60    fn set_mode(path: &Path, mode: u32) {
61        use std::os::unix::fs::PermissionsExt;
62        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
63    }
64
65    /// On Windows these calls shell out to `icacls`, resolved from `SystemRoot`
66    /// with a grant for `USERNAME` - and the platform tests mutate both
67    /// process-wide. Every test here that spawns it takes the same lock they
68    /// do, or a mutator's mid-flight environment turns a passing test into a
69    /// spawn failure.
70    #[cfg(windows)]
71    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
72        crate::platform::ENV_LOCK.lock().expect("env lock")
73    }
74
75    /// The point of `write_private` over `fs::write` + `chmod`: there is no
76    /// moment where the file exists at the umask default. The absence of that
77    /// window cannot be observed after the fact, so what is asserted is the mode
78    /// on a freshly created file - which the two-step version also reaches, but
79    /// only eventually.
80    #[test]
81    fn write_private_creates_an_owner_only_file_with_the_content() {
82        #[cfg(windows)]
83        let _env = env_lock();
84        let dir = tempfile::tempdir().unwrap();
85        let path = dir.path().join("secret");
86
87        write_private(&path, b"the key").unwrap();
88        assert_eq!(std::fs::read(&path).unwrap(), b"the key");
89        #[cfg(unix)]
90        assert_eq!(mode_of(&path), 0o600);
91    }
92
93    /// The mode passed to `open(2)` applies only on *creation*, so an existing
94    /// file keeps whatever permissions it had. Overwriting one that became
95    /// permissive must tighten it again.
96    #[test]
97    fn write_private_retightens_an_existing_permissive_file() {
98        #[cfg(windows)]
99        let _env = env_lock();
100        let dir = tempfile::tempdir().unwrap();
101        let path = dir.path().join("secret");
102        std::fs::write(&path, b"old").unwrap();
103        #[cfg(unix)]
104        set_mode(&path, 0o644);
105
106        write_private(&path, b"new").unwrap();
107        assert_eq!(std::fs::read(&path).unwrap(), b"new");
108        #[cfg(unix)]
109        assert_eq!(mode_of(&path), 0o600);
110    }
111
112    /// A failed secret write must never be mistaken for a successful one.
113    #[test]
114    fn write_private_propagates_an_unwritable_path() {
115        let dir = tempfile::tempdir().unwrap();
116        let path = dir.path().join("no-such-dir").join("secret");
117        assert!(write_private(&path, b"x").is_err());
118    }
119
120    #[test]
121    fn secure_file_perms_restricts_on_unix_and_succeeds_everywhere() {
122        #[cfg(windows)]
123        let _env = env_lock();
124        let dir = tempfile::tempdir().unwrap();
125        let path = dir.path().join("secret");
126        std::fs::write(&path, b"x").unwrap();
127        #[cfg(unix)]
128        set_mode(&path, 0o644);
129
130        secure_file_perms(&path).unwrap();
131
132        #[cfg(unix)]
133        assert_eq!(mode_of(&path), 0o600);
134    }
135
136    #[test]
137    fn secure_dir_perms_restricts_on_unix_and_succeeds_everywhere() {
138        #[cfg(windows)]
139        let _env = env_lock();
140        let dir = tempfile::tempdir().unwrap();
141        let sub = dir.path().join("d");
142        std::fs::create_dir(&sub).unwrap();
143        #[cfg(unix)]
144        set_mode(&sub, 0o755);
145
146        secure_dir_perms(&sub).unwrap();
147
148        #[cfg(unix)]
149        assert_eq!(mode_of(&sub), 0o700);
150    }
151
152    #[test]
153    fn secure_file_perms_missing_path_behavior() {
154        #[cfg(windows)]
155        let _env = env_lock();
156        // Unix `chmod` and Windows `icacls` both fail on a path that is not
157        // there; only a platform with no permission model at all succeeds,
158        // because it genuinely did nothing. Reporting the failure is the point:
159        // silently succeeding at protecting a file that does not exist is how a
160        // caller ends up believing a secret is restricted when it is not.
161        let dir = tempfile::tempdir().unwrap();
162        let missing = dir.path().join("nope");
163        let result = secure_file_perms(&missing);
164        #[cfg(any(unix, windows))]
165        assert!(result.is_err());
166        #[cfg(not(any(unix, windows)))]
167        assert!(result.is_ok());
168    }
169
170    #[test]
171    fn ensure_file_private_tightens_permissive_file_on_unix() {
172        #[cfg(windows)]
173        let _env = env_lock();
174        let dir = tempfile::tempdir().unwrap();
175        let path = dir.path().join("cfg");
176        std::fs::write(&path, b"x").unwrap();
177        #[cfg(unix)]
178        set_mode(&path, 0o644);
179
180        let previous = ensure_file_private(&path).unwrap();
181
182        #[cfg(unix)]
183        {
184            assert_eq!(previous, Some(0o100644));
185            assert_eq!(mode_of(&path), 0o600);
186        }
187        // Non-Unix always reports "already private / nothing to do".
188        #[cfg(not(unix))]
189        assert_eq!(previous, None);
190    }
191
192    #[test]
193    fn ensure_file_private_leaves_private_file_untouched() {
194        #[cfg(windows)]
195        let _env = env_lock();
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("cfg");
198        std::fs::write(&path, b"x").unwrap();
199        #[cfg(unix)]
200        set_mode(&path, 0o600);
201
202        assert_eq!(ensure_file_private(&path).unwrap(), None);
203
204        #[cfg(unix)]
205        assert_eq!(mode_of(&path), 0o600);
206    }
207
208    #[test]
209    fn ensure_file_private_is_noop_for_missing_path() {
210        let dir = tempfile::tempdir().unwrap();
211        let missing = dir.path().join("nope");
212        assert_eq!(ensure_file_private(&missing).unwrap(), None);
213    }
214}