Skip to main content

entracte_lib/
secure_io.rs

1//! User-only file helpers for secrets at rest.
2//!
3//! On **Unix**, the helpers explicitly chmod the file/dir to `0o600` /
4//! `0o700` so other local users on the same machine cannot read them.
5//!
6//! On **Windows** there is no chmod equivalent — the file inherits the
7//! ACL of its containing directory. We rely on Tauri placing our state
8//! inside `%LOCALAPPDATA%\<identifier>\`, which the OS already locks to
9//! the user's SID via NTFS inheritance. The `#[cfg(unix)]` blocks below
10//! are therefore intentionally Windows no-ops, not missing coverage.
11
12use std::fs::OpenOptions;
13use std::io::{self, Read, Write};
14use std::path::Path;
15
16/// Read a file, refusing to load more than `max_bytes`. Unbounded
17/// `fs::read_to_string` on attacker-controlled JSON is a denial-of-
18/// service primitive (a 4 GiB file gets loaded into RAM); every JSON
19/// state file we own should route through this. Returns
20/// `ErrorKind::InvalidData` when the file is too large so callers can
21/// distinguish from missing/permission errors.
22pub fn read_capped(path: &Path, max_bytes: u64) -> io::Result<String> {
23    let metadata = std::fs::metadata(path)?;
24    let size = metadata.len();
25    if size > max_bytes {
26        return Err(io::Error::new(
27            io::ErrorKind::InvalidData,
28            format!(
29                "{}: file is {size} bytes, exceeds cap of {max_bytes}",
30                path.display()
31            ),
32        ));
33    }
34    let file = std::fs::File::open(path)?;
35    let mut buf = String::with_capacity(size as usize);
36    file.take(max_bytes).read_to_string(&mut buf)?;
37    Ok(buf)
38}
39
40pub fn write_user_only(path: &Path, contents: &[u8]) -> io::Result<()> {
41    let dir = path
42        .parent()
43        .ok_or_else(|| io::Error::other("write_user_only: path has no parent"))?;
44    std::fs::create_dir_all(dir)?;
45    let file_name = path
46        .file_name()
47        .and_then(|n| n.to_str())
48        .ok_or_else(|| io::Error::other("write_user_only: invalid file name"))?;
49    let tmp = dir.join(format!(".{file_name}.tmp"));
50    let _ = std::fs::remove_file(&tmp);
51
52    let mut opts = OpenOptions::new();
53    opts.write(true).create_new(true);
54    #[cfg(unix)]
55    {
56        use std::os::unix::fs::OpenOptionsExt;
57        opts.mode(0o600);
58    }
59    let mut file = opts.open(&tmp)?;
60    file.write_all(contents)?;
61    file.sync_all()?;
62    drop(file);
63
64    if let Err(e) = std::fs::rename(&tmp, path) {
65        let _ = std::fs::remove_file(&tmp);
66        return Err(e);
67    }
68    Ok(())
69}
70
71pub fn ensure_user_only_dir(path: &Path) -> io::Result<()> {
72    std::fs::create_dir_all(path)?;
73    #[cfg(unix)]
74    {
75        use std::os::unix::fs::PermissionsExt;
76        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
77    }
78    Ok(())
79}
80
81pub fn tighten_existing_file(path: &Path) -> io::Result<()> {
82    if !path.exists() {
83        return Ok(());
84    }
85    #[cfg(unix)]
86    {
87        // `set_permissions` follows symlinks, so a chmod via path would
88        // hit whatever the link points at — a privilege manipulation
89        // primitive if an attacker can replace a rotated log with a
90        // symlink to a sensitive file between rotations and the sweep.
91        // Open the file (with `O_NOFOLLOW`) and `fchmod` the fd
92        // instead, so the chmod can only ever touch the inode we
93        // actually opened.
94        use std::os::unix::fs::OpenOptionsExt;
95        use std::os::unix::io::AsRawFd;
96        let file = OpenOptions::new()
97            .read(true)
98            .custom_flags(libc::O_NOFOLLOW)
99            .open(path)?;
100        let rc = unsafe { libc::fchmod(file.as_raw_fd(), 0o600) };
101        if rc != 0 {
102            return Err(io::Error::last_os_error());
103        }
104    }
105    #[cfg(not(unix))]
106    {
107        // Windows: `set_permissions` here only toggles the read-only
108        // bit, and the symlink-follow concern is Unix-specific. File
109        // protection on Windows comes from the inherited ACL of
110        // `%LOCALAPPDATA%\<identifier>\` — see the module docstring.
111        let _ = path;
112    }
113    Ok(())
114}
115
116pub fn tighten_existing_files_in_dir(dir: &Path) -> io::Result<()> {
117    let entries = match std::fs::read_dir(dir) {
118        Ok(e) => e,
119        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
120        Err(e) => return Err(e),
121    };
122    for entry in entries.flatten() {
123        let Ok(file_type) = entry.file_type() else {
124            continue;
125        };
126        // Skip symlinks even before delegating to `tighten_existing_file`
127        // — the inner helper already refuses to follow them, but
128        // filtering here keeps the intent visible at the call site
129        // and avoids an unnecessary `O_NOFOLLOW` open + error.
130        if file_type.is_symlink() || !file_type.is_file() {
131            continue;
132        }
133        let _ = tighten_existing_file(&entry.path());
134    }
135    Ok(())
136}
137
138/// One iteration of the periodic tighten sweep. Extracted from the
139/// `spawn_periodic_dir_tighten` loop so tests can drive a single tick
140/// synchronously instead of polling against a `thread::sleep` timer.
141pub fn tighten_once(dir: &Path) {
142    let _ = tighten_existing_files_in_dir(dir);
143}
144
145/// Spawn a detached background thread that re-runs
146/// `tighten_existing_files_in_dir(&dir)` every `interval`.
147///
148/// `tauri_plugin_log` creates rotated log files (`entracte.log.1`,
149/// `.log.2`, …) via `OpenOptions` without setting an explicit mode,
150/// so on Unix they pick up `0o644` from the process umask — wider
151/// than the `0o600` we promise. The startup-only tighten in `lib::run`
152/// misses everything created after boot. This periodic sweep closes
153/// that gap without coupling the log plugin to our security helpers.
154///
155/// Returns immediately; the thread runs for the process lifetime.
156/// Errors from individual `tighten_existing_file` calls are swallowed
157/// inside the helper (matching the startup path).
158pub fn spawn_periodic_dir_tighten(dir: std::path::PathBuf, interval: std::time::Duration) {
159    std::thread::spawn(move || loop {
160        std::thread::sleep(interval);
161        tighten_once(&dir);
162    });
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::test_support::temp_dir;
169
170    #[test]
171    fn read_capped_under_cap_returns_contents() {
172        let dir = temp_dir();
173        let path = dir.path().join("ok");
174        std::fs::write(&path, b"hello").unwrap();
175        let got = read_capped(&path, 1024).unwrap();
176        assert_eq!(got, "hello");
177    }
178
179    #[test]
180    fn read_capped_over_cap_errors_with_invalid_data() {
181        let dir = temp_dir();
182        let path = dir.path().join("big");
183        std::fs::write(&path, vec![b'x'; 2048]).unwrap();
184        let err = read_capped(&path, 1024).unwrap_err();
185        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
186    }
187
188    #[test]
189    fn read_capped_missing_returns_not_found() {
190        let dir = temp_dir();
191        let path = dir.path().join("nope");
192        let err = read_capped(&path, 1024).unwrap_err();
193        assert_eq!(err.kind(), io::ErrorKind::NotFound);
194    }
195
196    #[cfg(unix)]
197    #[test]
198    fn write_user_only_creates_at_0600() {
199        use std::os::unix::fs::PermissionsExt;
200        let dir = temp_dir();
201        let path = dir.path().join("secret");
202        write_user_only(&path, b"hello").unwrap();
203        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
204        assert_eq!(mode, 0o600);
205        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
206    }
207
208    #[cfg(unix)]
209    #[test]
210    fn write_user_only_overwrites_at_0600() {
211        use std::os::unix::fs::PermissionsExt;
212        let dir = temp_dir();
213        let path = dir.path().join("secret");
214        std::fs::write(&path, b"old").unwrap();
215        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
216        write_user_only(&path, b"new").unwrap();
217        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
218        assert_eq!(mode, 0o600);
219        assert_eq!(std::fs::read(&path).unwrap(), b"new");
220    }
221
222    #[cfg(unix)]
223    #[test]
224    fn write_user_only_cleans_stale_tmp() {
225        let dir = temp_dir();
226        let path = dir.path().join("secret");
227        let tmp = dir.path().join(".secret.tmp");
228        std::fs::write(&tmp, b"leftover").unwrap();
229        write_user_only(&path, b"fresh").unwrap();
230        assert!(!tmp.exists());
231        assert_eq!(std::fs::read(&path).unwrap(), b"fresh");
232    }
233
234    #[cfg(unix)]
235    #[test]
236    fn tighten_existing_file_drops_existing_file_to_0600() {
237        use std::os::unix::fs::PermissionsExt;
238        let dir = temp_dir();
239        let path = dir.path().join("entracte.log");
240        std::fs::write(&path, b"x").unwrap();
241        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
242        tighten_existing_file(&path).unwrap();
243        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
244        assert_eq!(mode, 0o600);
245    }
246
247    #[cfg(unix)]
248    #[test]
249    fn tighten_existing_file_does_not_follow_symlink_to_target() {
250        // Regression for the symlink-follow chmod primitive:
251        // tightening a symlink must not propagate the chmod to the
252        // link's target. The target stays at whatever mode it had.
253        use std::os::unix::fs::PermissionsExt;
254        let dir = temp_dir();
255        let target = dir.path().join("real-target");
256        std::fs::write(&target, b"sensitive").unwrap();
257        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
258        let link = dir.path().join("entracte.log.1");
259        std::os::unix::fs::symlink(&target, &link).unwrap();
260        // `tighten_existing_file` on the symlink must not error out
261        // the caller (the sweep keeps running) and must not touch the
262        // target's mode.
263        let _ = tighten_existing_file(&link);
264        let target_mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
265        assert_eq!(
266            target_mode, 0o644,
267            "target mode must be untouched when tightening a symlink"
268        );
269    }
270
271    #[cfg(unix)]
272    #[test]
273    fn tighten_existing_files_in_dir_skips_symlink_entries() {
274        // Same primitive, but through the directory sweep: a symlink
275        // sitting next to real log files must be skipped, not chmodded
276        // through to its target. The target lives in a *separate*
277        // directory so the sweep would never reach it directly — the
278        // only way it could be touched is via following the symlink.
279        use std::os::unix::fs::PermissionsExt;
280        let log_dir = temp_dir();
281        let target_dir = temp_dir();
282        let real = log_dir.path().join("entracte.log");
283        std::fs::write(&real, b"x").unwrap();
284        std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o644)).unwrap();
285        let target = target_dir.path().join("sensitive-target");
286        std::fs::write(&target, b"keep me").unwrap();
287        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
288        let link = log_dir.path().join("entracte.log.1");
289        std::os::unix::fs::symlink(&target, &link).unwrap();
290        tighten_existing_files_in_dir(log_dir.path()).unwrap();
291        let real_mode = std::fs::metadata(&real).unwrap().permissions().mode() & 0o777;
292        assert_eq!(real_mode, 0o600, "real file should still be tightened");
293        let target_mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
294        assert_eq!(
295            target_mode, 0o644,
296            "symlink target must not be chmodded by the sweep"
297        );
298    }
299
300    #[cfg(unix)]
301    #[test]
302    fn tighten_existing_files_in_dir_tightens_each_file() {
303        use std::os::unix::fs::PermissionsExt;
304        let dir = temp_dir();
305        for name in ["a.log", "b.log.1", "c.log.2"] {
306            let p = dir.path().join(name);
307            std::fs::write(&p, b"x").unwrap();
308            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
309        }
310        // Sub-dir should be skipped (only files).
311        std::fs::create_dir(dir.path().join("sub")).unwrap();
312        tighten_existing_files_in_dir(dir.path()).unwrap();
313        for name in ["a.log", "b.log.1", "c.log.2"] {
314            let mode = std::fs::metadata(dir.path().join(name))
315                .unwrap()
316                .permissions()
317                .mode()
318                & 0o777;
319            assert_eq!(mode, 0o600, "{name} should be 0o600");
320        }
321    }
322
323    #[test]
324    fn tighten_existing_files_in_dir_is_noop_when_missing() {
325        let dir = temp_dir();
326        let missing = dir.path().join("does-not-exist");
327        tighten_existing_files_in_dir(&missing).unwrap();
328        assert!(!missing.exists());
329    }
330
331    #[test]
332    fn tighten_existing_file_is_noop_when_missing() {
333        let dir = temp_dir();
334        let path = dir.path().join("does-not-exist.log");
335        tighten_existing_file(&path).unwrap();
336        assert!(!path.exists());
337    }
338
339    #[cfg(unix)]
340    #[test]
341    fn tighten_once_re_tightens_file_created_after_startup() {
342        // Simulates the log-rotation case: a file appears in the dir
343        // after the watcher has started, with default permissive perms,
344        // and one tighten tick drops it to 0o600. Driving `tighten_once`
345        // directly removes the prior reliance on `thread::sleep` timing.
346        use std::os::unix::fs::PermissionsExt;
347        let dir = temp_dir();
348        let path = dir.path().join("rotated.log.1");
349        std::fs::write(&path, b"after-rotation").unwrap();
350        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
351        tighten_once(dir.path());
352        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
353        assert_eq!(mode, 0o600);
354    }
355
356    #[cfg(unix)]
357    #[test]
358    fn ensure_user_only_dir_locks_existing_dir_to_0700() {
359        use std::os::unix::fs::PermissionsExt;
360        let dir = temp_dir();
361        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
362        ensure_user_only_dir(dir.path()).unwrap();
363        let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
364        assert_eq!(mode, 0o700);
365    }
366
367    // Cross-platform behavioural tests.
368    //
369    // The mode-checking tests above only run on Unix because Windows has
370    // no chmod equivalent — but the *file operations themselves*
371    // (create, overwrite, tmp cleanup, missing-path tolerance) must work
372    // on every platform. Without these, Windows CI would only exercise
373    // `tighten_existing_file_is_noop_when_missing`, which doesn't touch
374    // any of the file-creation logic.
375
376    #[test]
377    fn write_user_only_writes_expected_content() {
378        let dir = temp_dir();
379        let path = dir.path().join("secret");
380        write_user_only(&path, b"hello").unwrap();
381        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
382    }
383
384    #[test]
385    fn write_user_only_creates_parent_dir() {
386        let dir = temp_dir();
387        let nested = dir.path().join("nested").join("deep");
388        let path = nested.join("secret");
389        assert!(!nested.exists());
390        write_user_only(&path, b"x").unwrap();
391        assert!(path.exists());
392    }
393
394    #[test]
395    fn write_user_only_overwrites_existing_file() {
396        let dir = temp_dir();
397        let path = dir.path().join("secret");
398        std::fs::write(&path, b"old").unwrap();
399        write_user_only(&path, b"new").unwrap();
400        assert_eq!(std::fs::read(&path).unwrap(), b"new");
401    }
402
403    #[test]
404    fn write_user_only_removes_stale_tmp_before_writing() {
405        let dir = temp_dir();
406        let path = dir.path().join("secret");
407        let tmp = dir.path().join(".secret.tmp");
408        std::fs::write(&tmp, b"leftover").unwrap();
409        write_user_only(&path, b"fresh").unwrap();
410        assert!(!tmp.exists(), "stale tmp should be cleaned up");
411        assert_eq!(std::fs::read(&path).unwrap(), b"fresh");
412    }
413
414    #[test]
415    fn tighten_existing_file_returns_ok_on_real_file() {
416        // On Unix this drops to 0o600 (covered above); on Windows it's a
417        // documented no-op. Either way the call must succeed without
418        // erroring on a normal user-writable file.
419        let dir = temp_dir();
420        let path = dir.path().join("file.log");
421        std::fs::write(&path, b"x").unwrap();
422        tighten_existing_file(&path).unwrap();
423        assert!(path.exists());
424    }
425
426    #[test]
427    fn ensure_user_only_dir_creates_missing_dir() {
428        let dir = temp_dir();
429        let nested = dir.path().join("new-dir");
430        assert!(!nested.exists());
431        ensure_user_only_dir(&nested).unwrap();
432        assert!(nested.exists());
433    }
434
435    #[test]
436    fn ensure_user_only_dir_is_idempotent() {
437        let dir = temp_dir();
438        ensure_user_only_dir(dir.path()).unwrap();
439        ensure_user_only_dir(dir.path()).unwrap();
440        ensure_user_only_dir(dir.path()).unwrap();
441        assert!(dir.path().exists());
442    }
443
444    // Windows-specific test: the helpers must not error on Windows even
445    // though they don't touch the DACL. On Windows the protection comes
446    // from the file inheriting the ACL of `%LOCALAPPDATA%\<identifier>\`,
447    // not from anything this module does — see the module docstring.
448    //
449    // This test asserts the "no chmod, no problem" contract: the file
450    // round-trips through write_user_only + tighten_existing_file and is
451    // still readable/writable afterwards by the test process (which is
452    // the same SID that will own the file in production).
453    #[cfg(windows)]
454    #[test]
455    fn windows_round_trip_preserves_owner_access() {
456        let dir = temp_dir();
457        let path = dir.path().join("secret");
458        write_user_only(&path, b"original").unwrap();
459        // Re-read to confirm the test process can still read it after
460        // create_new + rename. (If the rename ever inherited a
461        // restrictive ACL without our SID, this would fail with
462        // ERROR_ACCESS_DENIED.)
463        assert_eq!(std::fs::read(&path).unwrap(), b"original");
464        tighten_existing_file(&path).unwrap();
465        assert_eq!(std::fs::read(&path).unwrap(), b"original");
466        std::fs::write(&path, b"rewritten").unwrap();
467        assert_eq!(std::fs::read(&path).unwrap(), b"rewritten");
468    }
469
470    #[cfg(windows)]
471    #[test]
472    fn windows_tighten_dir_iterates_without_erroring() {
473        let dir = temp_dir();
474        for name in ["a.log", "b.log", "c.log"] {
475            std::fs::write(dir.path().join(name), b"x").unwrap();
476        }
477        tighten_existing_files_in_dir(dir.path()).unwrap();
478        for name in ["a.log", "b.log", "c.log"] {
479            assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), b"x");
480        }
481    }
482}