1use std::fs::OpenOptions;
13use std::io::{self, Read, Write};
14use std::path::Path;
15
16pub 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 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 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 if file_type.is_symlink() || !file_type.is_file() {
131 continue;
132 }
133 let _ = tighten_existing_file(&entry.path());
134 }
135 Ok(())
136}
137
138pub fn tighten_once(dir: &Path) {
142 let _ = tighten_existing_files_in_dir(dir);
143}
144
145pub 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 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 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 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 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 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 #[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 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 #[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 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}