Skip to main content

zeph_common/
fs_secure.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Filesystem helpers that create files with owner-only permissions (0o600) on Unix.
5//!
6//! Every sensitive file written by Zeph (vault ciphertext, audit JSONL, debug dumps,
7//! router state, transcript sidecars) must be created through one of these helpers so
8//! that the permission guarantee is auditable in a single location.
9//!
10//! # Unix vs non-Unix
11//!
12//! On Unix the helpers set mode `0o600` via `OpenOptionsExt::mode`. On non-Unix
13//! platforms (Windows) the helpers fall back to plain [`OpenOptions`] without extra
14//! permissions — Windows uses ACLs rather than mode bits, and proper ACL hardening
15//! requires additional platform-specific code (TODO: tracked for a follow-up issue).
16//! The Windows fallback is **not atomic** for [`atomic_write_private`]: `std::fs::rename`
17//! fails with `ERROR_ALREADY_EXISTS` when the destination already exists, unlike the
18//! POSIX atomic-replace semantics.
19//!
20//! # Residual risks
21//!
22//! - The fixed `.tmp` suffix in [`atomic_write_private`] is a symlink-race target on
23//!   shared directories. Callers that open files in directories they do not own must
24//!   use `tempfile::NamedTempFile::persist` instead.
25//! - `SQLite` WAL/SHM sidecar files (`.db-wal`, `.db-shm`) are created by sqlx after the
26//!   pool opens and inherit the process umask. There is no way to prevent this without
27//!   upstream sqlx support; see `zeph-db` for best-effort post-open chmod.
28
29use std::fs::{File, OpenOptions};
30use std::io::{self, Write};
31use std::path::Path;
32
33/// Create or truncate `path` with owner-read/write-only permissions on Unix (0o600).
34///
35/// Returns a writable [`File`] handle. The caller is responsible for writing content
36/// and flushing. Use [`write_private`] for a one-shot write convenience.
37///
38/// On non-Unix platforms falls back to standard `OpenOptions` without extra permissions.
39///
40/// # Errors
41///
42/// Returns the underlying [`io::Error`] if the file cannot be opened or created.
43///
44/// # Examples
45///
46/// ```no_run
47/// use std::io::Write as _;
48/// use zeph_common::fs_secure;
49///
50/// let mut f = fs_secure::open_private_truncate(std::path::Path::new("/tmp/secret.txt"))?;
51/// f.write_all(b"hello")?;
52/// f.flush()?;
53/// # Ok::<(), std::io::Error>(())
54/// ```
55pub fn open_private_truncate(path: &Path) -> io::Result<File> {
56    #[cfg(unix)]
57    {
58        use std::os::unix::fs::OpenOptionsExt as _;
59        OpenOptions::new()
60            .write(true)
61            .create(true)
62            .truncate(true)
63            .mode(0o600)
64            .open(path)
65    }
66    #[cfg(not(unix))]
67    {
68        OpenOptions::new()
69            .write(true)
70            .create(true)
71            .truncate(true)
72            .open(path)
73    }
74}
75
76/// Open `path` in append mode, creating it with mode 0o600 on Unix if it does not exist.
77///
78/// Subsequent opens of an existing file do not change its permissions. Use this helper
79/// for JSONL log files (audit, transcript) that grow across multiple process invocations.
80///
81/// # Errors
82///
83/// Returns the underlying [`io::Error`] if the file cannot be opened or created.
84///
85/// # Examples
86///
87/// ```no_run
88/// use std::io::Write as _;
89/// use zeph_common::fs_secure;
90///
91/// let mut f = fs_secure::append_private(std::path::Path::new("/tmp/audit.jsonl"))?;
92/// writeln!(f, r#"{{"event":"start"}}"#)?;
93/// # Ok::<(), std::io::Error>(())
94/// ```
95pub fn append_private(path: &Path) -> io::Result<File> {
96    #[cfg(unix)]
97    {
98        use std::os::unix::fs::OpenOptionsExt as _;
99        OpenOptions::new()
100            .create(true)
101            .append(true)
102            .mode(0o600)
103            .open(path)
104    }
105    #[cfg(not(unix))]
106    {
107        OpenOptions::new().create(true).append(true).open(path)
108    }
109}
110
111/// Write `data` to `path`, creating or truncating the file with mode 0o600 on Unix.
112///
113/// This is a one-shot convenience wrapper around [`open_private_truncate`] that handles
114/// `write_all` and `flush`. For streaming writes use [`open_private_truncate`] directly.
115///
116/// # Errors
117///
118/// Returns the underlying [`io::Error`] if the file cannot be created, written to, or
119/// flushed.
120///
121/// # Examples
122///
123/// ```no_run
124/// use zeph_common::fs_secure;
125///
126/// fs_secure::write_private(std::path::Path::new("/tmp/dump.json"), b"{}")?;
127/// # Ok::<(), std::io::Error>(())
128/// ```
129pub fn write_private(path: &Path, data: &[u8]) -> io::Result<()> {
130    let mut f = open_private_truncate(path)?;
131    f.write_all(data)?;
132    f.flush()
133}
134
135/// Write `data` to `path` via a crash-safe replace: write to `<path>.tmp` (0o600 on
136/// Unix), fsync the tmp file, rename it over the target, then fsync the parent directory.
137///
138/// Using [`Path::with_added_extension`] preserves the original extension:
139/// `secrets.age` → `secrets.age.tmp` (not `secrets.tmp`).
140///
141/// On error during write or rename the `.tmp` file is removed to avoid orphan sidecars.
142/// Any stale `.tmp` from a prior crash is removed before creating the exclusive tmp file.
143///
144/// # Errors
145///
146/// Returns the underlying [`io::Error`] if any step fails. The target file is untouched
147/// when an error is returned.
148///
149/// # Examples
150///
151/// ```no_run
152/// use zeph_common::fs_secure;
153///
154/// fs_secure::atomic_write_private(std::path::Path::new("/tmp/state.json"), b"{}")?;
155/// # Ok::<(), std::io::Error>(())
156/// ```
157pub fn atomic_write_private(path: &Path, data: &[u8]) -> io::Result<()> {
158    let tmp = path.with_added_extension("tmp");
159
160    // Remove any stale .tmp leftover (crash or attacker symlink) before creating
161    // the tmp file exclusively. remove_file on a symlink removes the symlink itself,
162    // not the target, so O_EXCL then succeeds safely.
163    let _ = std::fs::remove_file(&tmp);
164
165    // Write and fsync the tmp file; clean up on any error.
166    let write_result = (|| -> io::Result<()> {
167        let mut f = open_private_exclusive(&tmp)?;
168        f.write_all(data)?;
169        f.flush()?;
170        f.sync_all()?;
171        Ok(())
172    })();
173    if let Err(e) = write_result {
174        let _ = std::fs::remove_file(&tmp);
175        return Err(e);
176    }
177
178    // Atomic rename; clean up tmp on failure.
179    std::fs::rename(&tmp, path).inspect_err(|_| {
180        let _ = std::fs::remove_file(&tmp);
181    })?;
182
183    // Fsync the parent directory so the rename is durable.
184    if let Some(parent) = path.parent()
185        && let Ok(dir) = File::open(parent)
186    {
187        let _ = dir.sync_all();
188    }
189
190    Ok(())
191}
192
193/// Create `path` exclusively (`O_EXCL` / `create_new`) with 0o600 on Unix.
194///
195/// Used internally by [`atomic_write_private`] for the `.tmp` file so that a
196/// pre-existing leftover or attacker-placed symlink is never silently followed.
197fn open_private_exclusive(path: &Path) -> io::Result<File> {
198    #[cfg(unix)]
199    {
200        use std::os::unix::fs::OpenOptionsExt as _;
201        OpenOptions::new()
202            .write(true)
203            .create_new(true)
204            .mode(0o600)
205            .open(path)
206    }
207    #[cfg(not(unix))]
208    {
209        OpenOptions::new().write(true).create_new(true).open(path)
210    }
211}
212
213// ── Tests ─────────────────────────────────────────────────────────────────────
214
215#[cfg(all(test, unix))]
216mod tests {
217    use super::*;
218    use std::os::unix::fs::PermissionsExt as _;
219
220    fn mode(path: &Path) -> u32 {
221        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
222    }
223
224    #[test]
225    fn write_private_creates_0600() {
226        let dir = tempfile::tempdir().unwrap();
227        let p = dir.path().join("secret.txt");
228        write_private(&p, b"hello").unwrap();
229        assert_eq!(mode(&p), 0o600);
230        assert_eq!(std::fs::read(&p).unwrap(), b"hello");
231    }
232
233    #[test]
234    fn atomic_write_private_overwrites_with_0600() {
235        let dir = tempfile::tempdir().unwrap();
236        let p = dir.path().join("state.json");
237        // Pre-create with 0o644 to verify the replace changes the mode.
238        {
239            use std::os::unix::fs::OpenOptionsExt as _;
240            OpenOptions::new()
241                .write(true)
242                .create(true)
243                .truncate(true)
244                .mode(0o644)
245                .open(&p)
246                .unwrap()
247                .write_all(b"old")
248                .unwrap();
249        }
250        atomic_write_private(&p, b"new").unwrap();
251        assert_eq!(mode(&p), 0o600);
252        assert_eq!(std::fs::read(&p).unwrap(), b"new");
253    }
254
255    #[test]
256    fn atomic_write_private_preserves_extension_appends_tmp() {
257        let dir = tempfile::tempdir().unwrap();
258        let p = dir.path().join("vault.age");
259        // The tmp path must be "vault.age.tmp", not "vault.tmp".
260        let tmp = p.with_added_extension("tmp");
261        assert_eq!(tmp.file_name().unwrap(), "vault.age.tmp");
262        atomic_write_private(&p, b"data").unwrap();
263        assert!(p.exists());
264        assert!(!tmp.exists(), "tmp must be cleaned up after success");
265    }
266
267    #[test]
268    fn atomic_write_private_cleans_tmp_on_success() {
269        let dir = tempfile::tempdir().unwrap();
270        let p = dir.path().join("data.json");
271        atomic_write_private(&p, b"{}").unwrap();
272        let tmp = p.with_added_extension("tmp");
273        assert!(!tmp.exists());
274    }
275
276    #[test]
277    fn atomic_write_private_errors_on_unwritable_dir() {
278        use std::os::unix::fs::PermissionsExt as _;
279        // Verify that atomic_write_private returns an error when the directory is
280        // not writable (no writeable dir → cannot create tmp), and the original
281        // target file is untouched.
282        let outer = tempfile::tempdir().unwrap();
283        let inner = outer.path().join("sub");
284        std::fs::create_dir(&inner).unwrap();
285        let p = inner.join("data.json");
286
287        // First write succeeds — establishes the file with content "first".
288        atomic_write_private(&p, b"first").unwrap();
289
290        // Make the directory read-only so the exclusive tmp create fails.
291        std::fs::set_permissions(&inner, std::fs::Permissions::from_mode(0o500)).unwrap();
292
293        let result = atomic_write_private(&p, b"second");
294        // Restore perms for tempdir cleanup before asserting (avoids double-fault).
295        std::fs::set_permissions(&inner, std::fs::Permissions::from_mode(0o700)).unwrap();
296
297        assert!(result.is_err(), "write to read-only dir must fail");
298        // Original file must be untouched.
299        assert_eq!(std::fs::read(&p).unwrap(), b"first");
300    }
301
302    #[test]
303    fn atomic_write_private_stale_tmp_removed_before_write() {
304        // Stale .tmp leftover should be removed before exclusive create.
305        let dir = tempfile::tempdir().unwrap();
306        let p = dir.path().join("state.json");
307        let tmp = p.with_added_extension("tmp");
308        std::fs::write(&tmp, b"stale").unwrap();
309        atomic_write_private(&p, b"fresh").unwrap();
310        assert_eq!(std::fs::read(&p).unwrap(), b"fresh");
311        assert!(!tmp.exists());
312    }
313
314    #[test]
315    fn append_private_creates_0600_on_new_file() {
316        let dir = tempfile::tempdir().unwrap();
317        let p = dir.path().join("audit.jsonl");
318        {
319            let mut f = append_private(&p).unwrap();
320            writeln!(f, "line1").unwrap();
321        }
322        assert_eq!(mode(&p), 0o600);
323    }
324
325    #[test]
326    fn append_private_preserves_mode_on_reopen() {
327        let dir = tempfile::tempdir().unwrap();
328        let p = dir.path().join("audit.jsonl");
329        {
330            let mut f = append_private(&p).unwrap();
331            writeln!(f, "line1").unwrap();
332        }
333        {
334            let mut f = append_private(&p).unwrap();
335            writeln!(f, "line2").unwrap();
336        }
337        assert_eq!(mode(&p), 0o600);
338        let content = std::fs::read_to_string(&p).unwrap();
339        assert!(content.contains("line1") && content.contains("line2"));
340    }
341}