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/// Open `path` for appending, owner-only (`0o600` on Unix, an owner-only ACL on
45/// Windows, plain elsewhere).
46///
47/// [`write_private`] covers a file written in one shot. A file that is appended
48/// to over time cannot use it, and opening one plainly creates it at the umask
49/// default. That is how a run's archive (`run.lvr`) and its stage logs ended up
50/// world-readable: nobody chose `0o644` for them, they inherited it, while the
51/// answer sidecar written beside them through `write_private` was owner-only.
52///
53/// The containing run directory is `0o700`, so those files were not reachable in
54/// place. Directory permissions do not survive a copy though, and `tar`, `rsync`
55/// or a backup tool preserves the per-file mode while dropping the protection
56/// the directory was providing.
57/// On Windows the restriction is best-effort: a failed ACL call still yields an
58/// open file. [`write_private`] guards secrets and fails instead, but these are
59/// a run's own files, which until now were created at the default and never
60/// restricted at all. Refusing to open one because `icacls` did not run would
61/// trade "less protected than intended" for "the run cannot record anything".
62pub fn open_private_append(path: &Path) -> io::Result<std::fs::File> {
63    crate::platform::open_append_with_mode(path, 0o600)
64}
65
66/// Create `path` and any missing parents, owner-only (`0o700` on Unix, an
67/// owner-only ACL on Windows, plain elsewhere).
68///
69/// `create_dir_all` makes directories at the umask default, typically `0o755`.
70/// [`secure_dir_perms`] fixes that afterwards, leaving a window; this closes it
71/// and is the right call for a directory created on a hot path, where the
72/// after-the-fact `chmod` is easy to forget.
73/// Best-effort on the restriction on Windows, for the reason
74/// [`open_private_append`] gives.
75pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
76    crate::platform::create_dir_all_with_mode(path, 0o700)
77}
78
79// Cross-platform tests: they run on every OS so the public API (and, on
80// non-Unix, the `fallback` no-op impls) is covered everywhere. Only the
81// Unix-specific *assertions* about concrete mode bits are gated behind
82// `#[cfg(unix)]`; on non-Unix the same public calls exercise the no-op
83// fallback, which succeeds and leaves permissions untouched.
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[cfg(unix)]
89    fn mode_of(path: &Path) -> u32 {
90        use std::os::unix::fs::PermissionsExt;
91        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
92    }
93
94    #[cfg(unix)]
95    fn set_mode(path: &Path, mode: u32) {
96        use std::os::unix::fs::PermissionsExt;
97        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
98    }
99
100    /// On Windows these calls shell out to `icacls`, resolved from `SystemRoot`
101    /// with a grant for `USERNAME` - and the platform tests mutate both
102    /// process-wide. Every test here that spawns it takes the same lock they
103    /// do, or a mutator's mid-flight environment turns a passing test into a
104    /// spawn failure.
105    #[cfg(windows)]
106    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
107        crate::platform::ENV_LOCK.lock().expect("env lock")
108    }
109
110    /// The point of `write_private` over `fs::write` + `chmod`: there is no
111    /// moment where the file exists at the umask default. The absence of that
112    /// window cannot be observed after the fact, so what is asserted is the mode
113    /// on a freshly created file - which the two-step version also reaches, but
114    /// only eventually.
115    #[test]
116    fn write_private_creates_an_owner_only_file_with_the_content() {
117        #[cfg(windows)]
118        let _env = env_lock();
119        let dir = tempfile::tempdir().unwrap();
120        let path = dir.path().join("secret");
121
122        write_private(&path, b"the key").unwrap();
123        assert_eq!(std::fs::read(&path).unwrap(), b"the key");
124        #[cfg(unix)]
125        assert_eq!(mode_of(&path), 0o600);
126    }
127
128    /// The mode passed to `open(2)` applies only on *creation*, so an existing
129    /// file keeps whatever permissions it had. Overwriting one that became
130    /// permissive must tighten it again.
131    #[test]
132    fn write_private_retightens_an_existing_permissive_file() {
133        #[cfg(windows)]
134        let _env = env_lock();
135        let dir = tempfile::tempdir().unwrap();
136        let path = dir.path().join("secret");
137        std::fs::write(&path, b"old").unwrap();
138        #[cfg(unix)]
139        set_mode(&path, 0o644);
140
141        write_private(&path, b"new").unwrap();
142        assert_eq!(std::fs::read(&path).unwrap(), b"new");
143        #[cfg(unix)]
144        assert_eq!(mode_of(&path), 0o600);
145    }
146
147    /// A failed secret write must never be mistaken for a successful one.
148    #[test]
149    fn write_private_propagates_an_unwritable_path() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("no-such-dir").join("secret");
152        assert!(write_private(&path, b"x").is_err());
153    }
154
155    /// The gap this closes: a file opened plainly for append is created at the
156    /// umask default, typically `0o644`. `run.lvr` and the stage logs were
157    /// created that way while the answer sidecar beside them was owner-only.
158    #[test]
159    fn open_private_append_creates_an_owner_only_file() {
160        #[cfg(windows)]
161        let _env = env_lock();
162        let dir = tempfile::tempdir().unwrap();
163        let path = dir.path().join("run.lvr");
164
165        {
166            use std::io::Write;
167            let mut f = open_private_append(&path).unwrap();
168            f.write_all(b"first").unwrap();
169        }
170
171        assert_eq!(std::fs::read(&path).unwrap(), b"first");
172        #[cfg(unix)]
173        assert_eq!(mode_of(&path), 0o600);
174    }
175
176    /// Appending is the common case, and it must add to the file rather than
177    /// truncate it: the archive is built up record by record over a whole run.
178    #[test]
179    fn open_private_append_adds_to_an_existing_file() {
180        #[cfg(windows)]
181        let _env = env_lock();
182        let dir = tempfile::tempdir().unwrap();
183        let path = dir.path().join("log");
184
185        for chunk in [b"one", b"two"] {
186            use std::io::Write;
187            let mut f = open_private_append(&path).unwrap();
188            f.write_all(chunk).unwrap();
189        }
190
191        assert_eq!(std::fs::read(&path).unwrap(), b"onetwo");
192    }
193
194    /// The mode passed to `open(2)` applies only on creation, so a file that
195    /// already exists at looser permissions has to be tightened. A run started
196    /// before this change has exactly that shape.
197    #[test]
198    fn open_private_append_retightens_an_existing_permissive_file() {
199        #[cfg(windows)]
200        let _env = env_lock();
201        let dir = tempfile::tempdir().unwrap();
202        let path = dir.path().join("run.lvr");
203        std::fs::write(&path, b"old").unwrap();
204        #[cfg(unix)]
205        set_mode(&path, 0o644);
206
207        drop(open_private_append(&path).unwrap());
208
209        #[cfg(unix)]
210        assert_eq!(mode_of(&path), 0o600);
211    }
212
213    #[test]
214    fn open_private_append_propagates_an_unwritable_path() {
215        let dir = tempfile::tempdir().unwrap();
216        let path = dir.path().join("no-such-dir").join("log");
217        assert!(open_private_append(&path).is_err());
218    }
219
220    #[test]
221    fn create_private_dir_all_makes_owner_only_directories() {
222        #[cfg(windows)]
223        let _env = env_lock();
224        let dir = tempfile::tempdir().unwrap();
225        let nested = dir.path().join("stages").join("0");
226
227        create_private_dir_all(&nested).unwrap();
228
229        assert!(nested.is_dir());
230        #[cfg(unix)]
231        assert_eq!(mode_of(&nested), 0o700);
232    }
233
234    /// Called on every stage line, so it has to be idempotent rather than
235    /// failing once the directory is there.
236    /// A failed create has to be reported, not swallowed. The caller decides
237    /// whether it can carry on without the directory; it cannot decide that if
238    /// it was told the directory is there.
239    #[test]
240    fn create_private_dir_all_propagates_a_path_it_cannot_create() {
241        #[cfg(windows)]
242        let _env = env_lock();
243        let dir = tempfile::tempdir().unwrap();
244        // A file where a parent directory would have to be.
245        let blocker = dir.path().join("not-a-dir");
246        std::fs::write(&blocker, b"x").unwrap();
247
248        assert!(create_private_dir_all(&blocker.join("child")).is_err());
249        // And a file sitting exactly where the directory should be. Reported
250        // rather than mistaken for "it is already there".
251        assert!(create_private_dir_all(&blocker).is_err());
252    }
253
254    #[test]
255    fn create_private_dir_all_is_idempotent() {
256        #[cfg(windows)]
257        let _env = env_lock();
258        let dir = tempfile::tempdir().unwrap();
259        let nested = dir.path().join("stages").join("0");
260
261        create_private_dir_all(&nested).unwrap();
262        create_private_dir_all(&nested).unwrap();
263
264        assert!(nested.is_dir());
265    }
266
267    #[test]
268    fn secure_file_perms_restricts_on_unix_and_succeeds_everywhere() {
269        #[cfg(windows)]
270        let _env = env_lock();
271        let dir = tempfile::tempdir().unwrap();
272        let path = dir.path().join("secret");
273        std::fs::write(&path, b"x").unwrap();
274        #[cfg(unix)]
275        set_mode(&path, 0o644);
276
277        secure_file_perms(&path).unwrap();
278
279        #[cfg(unix)]
280        assert_eq!(mode_of(&path), 0o600);
281    }
282
283    #[test]
284    fn secure_dir_perms_restricts_on_unix_and_succeeds_everywhere() {
285        #[cfg(windows)]
286        let _env = env_lock();
287        let dir = tempfile::tempdir().unwrap();
288        let sub = dir.path().join("d");
289        std::fs::create_dir(&sub).unwrap();
290        #[cfg(unix)]
291        set_mode(&sub, 0o755);
292
293        secure_dir_perms(&sub).unwrap();
294
295        #[cfg(unix)]
296        assert_eq!(mode_of(&sub), 0o700);
297    }
298
299    #[test]
300    fn secure_file_perms_missing_path_behavior() {
301        #[cfg(windows)]
302        let _env = env_lock();
303        // Unix `chmod` and Windows `icacls` both fail on a path that is not
304        // there; only a platform with no permission model at all succeeds,
305        // because it genuinely did nothing. Reporting the failure is the point:
306        // silently succeeding at protecting a file that does not exist is how a
307        // caller ends up believing a secret is restricted when it is not.
308        let dir = tempfile::tempdir().unwrap();
309        let missing = dir.path().join("nope");
310        let result = secure_file_perms(&missing);
311        #[cfg(any(unix, windows))]
312        assert!(result.is_err());
313        #[cfg(not(any(unix, windows)))]
314        assert!(result.is_ok());
315    }
316
317    #[test]
318    fn ensure_file_private_tightens_permissive_file_on_unix() {
319        #[cfg(windows)]
320        let _env = env_lock();
321        let dir = tempfile::tempdir().unwrap();
322        let path = dir.path().join("cfg");
323        std::fs::write(&path, b"x").unwrap();
324        #[cfg(unix)]
325        set_mode(&path, 0o644);
326
327        let previous = ensure_file_private(&path).unwrap();
328
329        #[cfg(unix)]
330        {
331            assert_eq!(previous, Some(0o100644));
332            assert_eq!(mode_of(&path), 0o600);
333        }
334        // Non-Unix always reports "already private / nothing to do".
335        #[cfg(not(unix))]
336        assert_eq!(previous, None);
337    }
338
339    #[test]
340    fn ensure_file_private_leaves_private_file_untouched() {
341        #[cfg(windows)]
342        let _env = env_lock();
343        let dir = tempfile::tempdir().unwrap();
344        let path = dir.path().join("cfg");
345        std::fs::write(&path, b"x").unwrap();
346        #[cfg(unix)]
347        set_mode(&path, 0o600);
348
349        assert_eq!(ensure_file_private(&path).unwrap(), None);
350
351        #[cfg(unix)]
352        assert_eq!(mode_of(&path), 0o600);
353    }
354
355    #[test]
356    fn ensure_file_private_is_noop_for_missing_path() {
357        let dir = tempfile::tempdir().unwrap();
358        let missing = dir.path().join("nope");
359        assert_eq!(ensure_file_private(&missing).unwrap(), None);
360    }
361}