1use std::ffi::CString;
19use std::fs;
20use std::io::{self, Read, Write};
21use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
22use std::path::Path;
23
24const TEMP_ATTEMPTS: usize = 32;
25
26fn cstr(s: &str) -> io::Result<CString> {
27 CString::new(s)
28 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL byte in path component"))
29}
30
31fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> io::Result<RawFd> {
32 let c = cstr(name)?;
33 let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
36 if fd < 0 {
37 Err(io::Error::last_os_error())
38 } else {
39 Ok(fd)
40 }
41}
42
43fn openat_dir(dirfd: RawFd, name: &str) -> io::Result<OwnedFd> {
44 let fd = openat_raw(
45 dirfd,
46 name,
47 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
48 0,
49 )?;
50 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
52}
53
54fn walk_dir(path: &Path, create_mode: Option<u32>) -> io::Result<OwnedFd> {
61 let abs = if path.is_absolute() {
62 path.to_path_buf()
63 } else {
64 std::env::current_dir()?.join(path)
65 };
66 let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
67 for comp in abs.components() {
68 use std::path::Component;
69 match comp {
70 Component::RootDir | Component::CurDir => {}
71 Component::Normal(name) => {
72 let name = name.to_str().ok_or_else(|| {
73 io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path component")
74 })?;
75 let next = match openat_dir(dir.as_raw_fd(), name) {
76 Ok(fd) => fd,
77 Err(e) if e.kind() == io::ErrorKind::NotFound && create_mode.is_some() => {
78 let c = cstr(name)?;
79 if unsafe {
83 libc::mkdirat(
84 dir.as_raw_fd(),
85 c.as_ptr(),
86 create_mode.unwrap() as libc::mode_t,
87 )
88 } != 0
89 {
90 if io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST) {
94 openat_dir(dir.as_raw_fd(), name)?
95 } else {
96 return Err(io::Error::last_os_error());
97 }
98 } else {
99 openat_dir(dir.as_raw_fd(), name)?
100 }
101 }
102 Err(e) => return Err(e),
103 };
104 dir = next;
105 }
106 Component::ParentDir => {
107 return Err(io::Error::new(
108 io::ErrorKind::InvalidInput,
109 ".. in state path not allowed",
110 ));
111 }
112 Component::Prefix(_) => unreachable!("non-Windows path"),
113 }
114 }
115 Ok(dir)
116}
117
118fn open_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
121 walk_dir(path, None)
122}
123
124fn fstat(fd: RawFd) -> io::Result<libc::stat> {
125 let mut st: libc::stat = unsafe { std::mem::zeroed() };
126 if unsafe { libc::fstat(fd, &mut st) } != 0 {
128 Err(io::Error::last_os_error())
129 } else {
130 Ok(st)
131 }
132}
133
134fn unlink_name(dirfd: RawFd, name: &str) -> io::Result<()> {
135 let c = cstr(name)?;
136 if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
139 Err(io::Error::last_os_error())
140 } else {
141 Ok(())
142 }
143}
144
145fn basename(target: &Path) -> io::Result<String> {
146 target
147 .file_name()
148 .map(|n| n.to_string_lossy().into_owned())
149 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))
150}
151
152fn parent_dir(target: &Path) -> &Path {
153 target
154 .parent()
155 .filter(|p| !p.as_os_str().is_empty())
156 .unwrap_or_else(|| Path::new("."))
157}
158
159fn open_parent_nofollow(target: &Path) -> io::Result<OwnedFd> {
167 let dir = open_dir_nofollow(parent_dir(target))?;
168 let st = fstat(dir.as_raw_fd())?;
169 let euid = unsafe { libc::geteuid() };
170 if st.st_uid != euid {
171 return Err(io::Error::new(
172 io::ErrorKind::PermissionDenied,
173 format!(
174 "parent directory is owned by uid {}, not euid {}",
175 st.st_uid, euid
176 ),
177 ));
178 }
179 Ok(dir)
180}
181
182struct TmpGuard {
183 dirfd: RawFd,
184 name: String,
185}
186
187impl Drop for TmpGuard {
188 fn drop(&mut self) {
189 let _ = unlink_name(self.dirfd, &self.name);
190 }
191}
192
193pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> io::Result<()> {
201 let target = path.as_ref();
202 let file_name = basename(target)?;
203 let dir = open_parent_nofollow(target)?;
204 let dirfd = dir.as_raw_fd();
205
206 for attempt in 0..TEMP_ATTEMPTS {
207 let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
208 match openat_raw(
209 dirfd,
210 &tmp_name,
211 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
212 0o600,
213 ) {
214 Ok(raw) => {
215 let mut file = unsafe { fs::File::from_raw_fd(raw) };
218 let _guard = TmpGuard {
219 dirfd,
220 name: tmp_name.clone(),
221 };
222 file.write_all(content)?;
223 file.sync_all()?;
224 drop(file);
225 let c_tmp = cstr(&tmp_name)?;
226 let c_final = cstr(&file_name)?;
227 if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
231 return Err(io::Error::last_os_error());
232 }
233 std::mem::forget(_guard);
235 return Ok(());
236 }
237 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
238 Err(e) => return Err(e),
239 }
240 }
241
242 Err(io::Error::new(
243 io::ErrorKind::AlreadyExists,
244 "could not reserve a unique temp name",
245 ))
246}
247
248pub fn read_nofollow(path: impl AsRef<Path>) -> io::Result<String> {
255 let target = path.as_ref();
256 let file_name = basename(target)?;
257 let dir = open_parent_nofollow(target)?;
258 let fd = openat_raw(
259 dir.as_raw_fd(),
260 &file_name,
261 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
262 0,
263 )?;
264 let mut file = unsafe { fs::File::from_raw_fd(fd) };
266 let mut content = String::new();
267 file.read_to_string(&mut content)?;
268 Ok(content)
269}
270
271pub fn open_append_nofollow(path: impl AsRef<Path>) -> io::Result<fs::File> {
277 let target = path.as_ref();
278 let file_name = basename(target)?;
279 let dir = open_parent_nofollow(target)?;
280 let fd = openat_raw(
281 dir.as_raw_fd(),
282 &file_name,
283 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
284 0o644,
285 )?;
286 Ok(unsafe { fs::File::from_raw_fd(fd) })
288}
289
290pub fn remove_nofollow(path: impl AsRef<Path>) -> io::Result<()> {
294 let target = path.as_ref();
295 let file_name = basename(target)?;
296 let dir = open_parent_nofollow(target)?;
297 unlink_name(dir.as_raw_fd(), &file_name)
298}
299
300pub fn ensure_state_dir(dir: impl AsRef<Path>) -> io::Result<OwnedFd> {
305 let dir = dir.as_ref();
306 let fd = walk_dir(dir, Some(0o700))?;
310 let st = fstat(fd.as_raw_fd())?;
311 let euid = unsafe { libc::geteuid() };
312 if st.st_uid != euid {
313 return Err(io::Error::new(
314 io::ErrorKind::PermissionDenied,
315 format!(
316 "state directory is owned by uid {}, not euid {}",
317 st.st_uid, euid
318 ),
319 ));
320 }
321 Ok(fd)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use std::os::unix::fs::symlink;
328 use std::path::PathBuf;
329
330 fn tmpdir(name: &str) -> PathBuf {
331 let d = std::env::temp_dir().join(format!(
332 "coreshift_safe_fs_dir_{}_{name}",
333 std::process::id()
334 ));
335 let _ = fs::remove_dir_all(&d);
336 fs::create_dir_all(&d).unwrap();
337 d
338 }
339
340 #[test]
341 fn test_write_atomic_creates_regular_file() {
342 let dir = tmpdir("w");
343 let p = dir.join("out.txt");
344
345 write_atomic(&p, b"hello").unwrap();
346 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
347 assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
348 }
349
350 #[test]
351 fn test_write_atomic_replaces_existing_symlink_not_target() {
352 let dir = tmpdir("s1");
353 let target = dir.join("victim");
354 let link = dir.join("link");
355
356 fs::write(&target, b"precious").unwrap();
357 symlink(&target, &link).unwrap();
358
359 write_atomic(&link, b"new").unwrap();
362
363 assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
364 assert_eq!(fs::read_to_string(&link).unwrap(), "new");
365 assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
366 }
367
368 #[test]
369 fn test_read_nofollow_refuses_symlink() {
370 let dir = tmpdir("r");
371 let target = dir.join("victim2");
372 let link = dir.join("link2");
373
374 fs::write(&target, b"secret").unwrap();
375 symlink(&target, &link).unwrap();
376
377 assert_eq!(read_nofollow(&target).unwrap(), "secret");
378 assert!(read_nofollow(&link).is_err());
379 }
380
381 #[test]
382 fn test_open_append_nofollow_refuses_symlink() {
383 let dir = tmpdir("a");
384 let target = dir.join("target3");
385 let link = dir.join("link3");
386
387 fs::write(&target, b"x").unwrap();
388 symlink(&target, &link).unwrap();
389
390 assert!(open_append_nofollow(&target).is_ok());
392 assert!(open_append_nofollow(&link).is_err());
394
395 let _ = fs::remove_file(&target);
396 let _ = fs::remove_file(&link);
397 }
398
399 #[test]
400 fn test_write_atomic_refuses_symlinked_parent() {
401 let dir = tmpdir("parent_symlink");
405 let elsewhere = tmpdir("parent_dest");
406 let link = dir.join("coreshift");
407 symlink(&elsewhere, &link).unwrap();
408
409 assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
410 assert!(!elsewhere.join("payload.txt").exists());
411 assert!(!link.join("payload.txt").exists());
412 }
413
414 #[test]
415 fn test_read_nofollow_refuses_symlinked_parent() {
416 let dir = tmpdir("read_parent_symlink");
417 let elsewhere = tmpdir("read_parent_dest");
418 fs::write(elsewhere.join("conf"), b"injected").unwrap();
419 let link = dir.join("coreshift");
420 symlink(&elsewhere, &link).unwrap();
421
422 assert!(read_nofollow(link.join("conf")).is_err());
423 }
424
425 #[test]
426 fn test_open_append_nofollow_refuses_symlinked_parent() {
427 let dir = tmpdir("append_parent_symlink");
428 let elsewhere = tmpdir("append_parent_dest");
429 let link = dir.join("coreshift");
430 symlink(&elsewhere, &link).unwrap();
431
432 assert!(open_append_nofollow(link.join("daemon.log")).is_err());
433 assert!(!elsewhere.join("daemon.log").exists());
434 }
435
436 #[test]
437 fn test_ensure_state_dir_refuses_symlink() {
438 let dir = tmpdir("state_symlink");
439 let elsewhere = tmpdir("state_dest");
440 let link = dir.join("state");
441 symlink(&elsewhere, &link).unwrap();
442
443 assert!(ensure_state_dir(&link).is_err());
444 let real = tmpdir("state_real");
446 assert!(ensure_state_dir(&real).is_ok());
447 }
448
449 #[test]
450 fn test_remove_nofollow_removes_entry_not_target() {
451 let dir = tmpdir("unlink");
452 let target = dir.join("victim4");
453 let link = dir.join("link4");
454 fs::write(&target, b"keep").unwrap();
455 symlink(&target, &link).unwrap();
456
457 remove_nofollow(&link).unwrap();
458 assert!(!link.exists());
459 assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
460 }
461
462 #[test]
463 fn test_write_atomic_requires_parent_to_exist() {
464 let dir = tmpdir("missing_parent");
465 let p = dir.join("nope").join("file.txt");
466
467 assert!(write_atomic(&p, b"x").is_err());
468 assert!(!p.exists());
469 }
470
471 #[test]
472 fn test_ops_refuse_foreign_owned_parent() {
473 if unsafe { libc::geteuid() } != 0 {
478 return;
479 }
480 let dir = tmpdir("foreign_owner");
481 let path = dir.join("f");
482 let owned = cstr(&dir.to_string_lossy()).unwrap();
483 assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
485
486 assert!(write_atomic(&path, b"x").is_err());
487 assert!(read_nofollow(&path).is_err());
488 assert!(open_append_nofollow(&path).is_err());
489 assert!(remove_nofollow(&path).is_err());
490 assert!(ensure_state_dir(&dir).is_err());
491 assert!(!path.exists());
492 }
493
494 fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
495 let prefix = format!(".{file_name}.");
496 fs::read_dir(dir)
497 .map(|rd| {
498 rd.filter_map(|e| e.ok())
499 .filter_map(|e| e.file_name().to_str().map(str::to_owned))
500 .filter(|n| n.starts_with(&prefix))
501 .collect()
502 })
503 .unwrap_or_default()
504 }
505
506 #[test]
507 fn test_write_atomic_cleans_temp_on_rename_failure() {
508 let dir = tmpdir("rename_fail");
512 let dest = dir.join("dest");
513 fs::create_dir_all(&dest).unwrap();
514 fs::write(dest.join("keep"), b"x").unwrap();
515
516 assert!(write_atomic(&dest, b"boom").is_err());
517 assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
518 assert!(
519 temp_leftovers(&dir, "dest").is_empty(),
520 "temp file must be cleaned up"
521 );
522 }
523
524 #[test]
525 fn test_write_atomic_leaves_no_temp_on_success() {
526 let dir = tmpdir("no_temp_success");
527 let p = dir.join("out.txt");
528
529 write_atomic(&p, b"hello").unwrap();
530 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
531 assert!(
532 temp_leftovers(&dir, "out.txt").is_empty(),
533 "no temp left behind"
534 );
535 }
536
537 #[test]
538 fn test_write_atomic_retries_when_temp_name_exists() {
539 let dir = tmpdir("temp_collision");
540 let p = dir.join("out.txt");
541 let pid = std::process::id();
542 let collided = dir.join(format!(".out.txt.{pid}.0"));
543 fs::write(&collided, b"not mine").unwrap();
544
545 write_atomic(&p, b"hello").unwrap();
546 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
547 assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
550 let leftovers = temp_leftovers(&dir, "out.txt");
551 assert_eq!(
552 leftovers.len(),
553 1,
554 "only the pre-existing colliding temp remains"
555 );
556 assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
557 }
558
559 #[test]
560 fn test_ops_refuse_foreign_owned_writable_parent() {
561 use std::os::unix::fs::MetadataExt;
567 let euid = unsafe { libc::geteuid() };
568 if euid == 0 {
569 return;
570 }
571 let tmp = std::env::temp_dir();
572 let meta = match fs::symlink_metadata(&tmp) {
573 Ok(m) => m,
574 Err(_) => return,
575 };
576 if meta.uid() == euid {
577 return; }
579 if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
580 return; }
582 let p = tmp.join(format!(
583 "coreshift_fs_foreign_{}_{}",
584 std::process::id(),
585 "out"
586 ));
587 let _ = fs::remove_file(&p);
588 fs::write(&p, b"probe").unwrap();
589
590 assert!(read_nofollow(&p).is_err());
591 assert!(open_append_nofollow(&p).is_err());
592 assert!(write_atomic(&p, b"boom").is_err());
593 assert!(remove_nofollow(&p).is_err());
594 assert!(ensure_state_dir(&tmp).is_err());
595 assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
596 let _ = fs::remove_file(&p);
597 }
598
599 #[test]
600 fn test_ensure_state_dir_creates_fresh_dir() {
601 let base = tmpdir("fresh_base");
602 let nested = base.join("a").join("b").join("state");
603
604 let fd = ensure_state_dir(&nested).unwrap();
605 assert!(nested.is_dir());
606 drop(fd);
607 assert!(ensure_state_dir(&nested).is_ok());
608 }
609
610 #[test]
611 fn test_open_append_nofollow_refuses_dangling_symlink() {
612 let dir = tmpdir("dangling_append");
613 let missing = dir.join("not_there.txt");
614 let link = dir.join("linkd");
615 symlink(&missing, &link).unwrap();
616
617 assert!(open_append_nofollow(&link).is_err());
618 assert!(
619 !missing.exists(),
620 "must not create the target through a dangling link"
621 );
622 }
623
624 #[test]
625 fn test_read_nofollow_refuses_dangling_symlink() {
626 let dir = tmpdir("dangling_read");
627 let missing = dir.join("not_there2.txt");
628 let link = dir.join("linkd2");
629 symlink(&missing, &link).unwrap();
630
631 assert!(read_nofollow(&link).is_err());
632 assert!(!missing.exists());
633 }
634
635 #[test]
636 fn test_ops_refuse_regular_file_parent() {
637 let dir = tmpdir("regfile_parent");
640 let f = dir.join("notadir");
641 fs::write(&f, b"x").unwrap();
642
643 assert!(write_atomic(f.join("out"), b"y").is_err());
644 assert!(read_nofollow(f.join("out")).is_err());
645 assert!(open_append_nofollow(f.join("out")).is_err());
646 assert!(remove_nofollow(f.join("out")).is_err());
647 assert!(ensure_state_dir(&f).is_err());
648 assert_eq!(fs::read_to_string(&f).unwrap(), "x");
649 }
650}