entracte_lib/
secure_io.rs1use 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 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 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 if file_type.is_symlink() || !file_type.is_file() {
107 continue;
108 }
109 let _ = tighten_existing_file(&entry.path());
110 }
111 Ok(())
112}
113
114pub fn tighten_once(dir: &Path) {
118 let _ = tighten_existing_files_in_dir(dir);
119}
120
121pub 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 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 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 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 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 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 #[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 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 #[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 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}