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