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}