1use crate::CoreError;
12use std::ffi::CString;
13use std::fs;
14use std::io::{Read, Write};
15use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
16use std::path::Path;
17use std::time::UNIX_EPOCH;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct PathFingerprint {
21 pub len: u64,
22 pub modified_ns: u128,
23}
24
25pub fn path_fingerprint(path: &Path) -> Result<PathFingerprint, CoreError> {
31 let metadata = std::fs::metadata(path).map_err(|err| {
32 CoreError::sys(err.raw_os_error().unwrap_or(libc::EIO), "path_fingerprint")
33 })?;
34 let modified_ns = metadata
35 .modified()
36 .ok()
37 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
38 .map(|duration| duration.as_nanos())
39 .unwrap_or_default();
40 Ok(PathFingerprint {
41 len: metadata.len(),
42 modified_ns,
43 })
44}
45
46pub fn path_fingerprint_nofollow(path: &Path) -> Result<PathFingerprint, CoreError> {
59 let target = path;
60 let file_name = basename(target)?;
61 let dir = open_parent_nofollow(target)?;
62 let fd = openat_raw(
63 dir.as_raw_fd(),
64 &file_name,
65 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
66 0,
67 )?;
68 let metadata = unsafe { fs::File::from_raw_fd(fd) }.metadata()?;
70 let modified_ns = metadata
71 .modified()
72 .ok()
73 .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
74 .map(|duration| duration.as_nanos())
75 .unwrap_or_default();
76 Ok(PathFingerprint {
77 len: metadata.len(),
78 modified_ns,
79 })
80}
81
82pub fn path_exists(path: &str) -> bool {
89 match std::ffi::CString::new(path) {
90 Ok(c) => unsafe { libc::access(c.as_ptr(), libc::F_OK) == 0 },
91 Err(_) => false,
92 }
93}
94
95pub fn path_lstat_exists(path: &str) -> bool {
99 match std::ffi::CString::new(path) {
100 Ok(c) => unsafe {
101 let mut stat = std::mem::zeroed();
102 libc::lstat(c.as_ptr(), &mut stat) == 0
103 },
104 Err(_) => false,
105 }
106}
107
108pub fn read_to_string(path: &str) -> Result<String, CoreError> {
119 std::fs::read_to_string(path)
120 .map_err(|err| CoreError::sys(err.raw_os_error().unwrap_or(libc::EIO), "read_to_string"))
121}
122
123pub fn readahead(fd: impl AsRawFd, offset: u64, len: usize) -> Result<(), CoreError> {
138 readahead_raw(fd.as_raw_fd(), offset, len)
139}
140
141pub const FADV_NORMAL: i32 = libc::POSIX_FADV_NORMAL;
144pub const FADV_RANDOM: i32 = libc::POSIX_FADV_RANDOM;
145pub const FADV_SEQUENTIAL: i32 = libc::POSIX_FADV_SEQUENTIAL;
146pub const FADV_WILLNEED: i32 = libc::POSIX_FADV_WILLNEED;
147pub const FADV_DONTNEED: i32 = libc::POSIX_FADV_DONTNEED;
148pub const FADV_NOREUSE: i32 = libc::POSIX_FADV_NOREUSE;
149
150pub fn fadvise(fd: impl AsRawFd, offset: u64, len: usize, advice: i32) -> Result<(), CoreError> {
163 let ret = unsafe {
164 libc::posix_fadvise(
165 fd.as_raw_fd(),
166 offset as libc::off_t,
167 len as libc::off_t,
168 advice,
169 )
170 };
171 if ret == 0 {
172 Ok(())
173 } else {
174 Err(CoreError::sys(ret, "posix_fadvise"))
175 }
176}
177
178pub fn mmap_madvise(
189 fd: impl AsRawFd,
190 offset: u64,
191 len: usize,
192 touch: bool,
193) -> Result<(), CoreError> {
194 mmap_madvise_raw(fd.as_raw_fd(), offset, len, touch)
195}
196
197#[cfg(any(target_os = "linux", target_os = "android"))]
198fn mmap_madvise_raw(
199 fd: libc::c_int,
200 offset: u64,
201 len: usize,
202 touch: bool,
203) -> Result<(), CoreError> {
204 if len == 0 {
205 return Ok(());
206 }
207
208 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
209 if page_size <= 0 {
210 return Err(CoreError::sys(libc::EINVAL, "sysconf(_SC_PAGESIZE)"));
211 }
212 let page_size = page_size as u64;
213 if offset % page_size != 0 || offset > libc::off_t::MAX as u64 {
214 return Err(CoreError::sys(libc::EINVAL, "mmap"));
215 }
216
217 let file_len = {
221 let mut st: libc::stat = unsafe { std::mem::zeroed() };
222 if unsafe { libc::fstat(fd, &mut st) } == -1 {
223 let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
224 return Err(CoreError::sys(code, "fstat"));
225 }
226 st.st_size.max(0) as u64
227 };
228 let len = len.min(file_len.saturating_sub(offset) as usize);
229 if len == 0 {
230 return Ok(());
231 }
232
233 let ptr = unsafe {
234 libc::mmap(
235 std::ptr::null_mut(),
236 len,
237 libc::PROT_READ,
238 libc::MAP_PRIVATE,
239 fd,
240 offset as libc::off_t,
241 )
242 };
243 if ptr == libc::MAP_FAILED {
244 let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
245 return Err(CoreError::sys(code, "mmap"));
246 }
247
248 let result = if unsafe { libc::madvise(ptr, len, libc::MADV_WILLNEED) } == -1 {
249 let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
250 Err(CoreError::sys(code, "madvise"))
251 } else {
252 if touch {
253 let page_size = page_size as usize;
262 let mut pos = 0usize;
263 while pos < len {
264 let mut st: libc::stat = unsafe { std::mem::zeroed() };
265 if unsafe { libc::fstat(fd, &mut st) } == -1 {
266 break;
267 }
268 let file_len = st.st_size.max(0) as u64;
269 let page_start = offset.saturating_add(pos as u64);
270 if page_start >= file_len {
271 break;
272 }
273 unsafe {
274 std::ptr::read_volatile((ptr as *const u8).add(pos));
275 }
276 pos = pos.saturating_add(page_size);
277 }
278 }
279 Ok(())
280 };
281
282 if unsafe { libc::munmap(ptr, len) } == -1 {
283 let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
284 return Err(CoreError::sys(code, "munmap"));
285 }
286 result
287}
288
289#[cfg(not(any(target_os = "linux", target_os = "android")))]
290fn mmap_madvise_raw(
291 _fd: libc::c_int,
292 _offset: u64,
293 _len: usize,
294 _touch: bool,
295) -> Result<(), CoreError> {
296 Err(CoreError::sys(libc::ENOSYS, "mmap"))
297}
298
299#[cfg(any(target_os = "linux", target_os = "android"))]
300fn readahead_raw(fd: libc::c_int, offset: u64, len: usize) -> Result<(), CoreError> {
301 if offset > libc::off64_t::MAX as u64 {
302 return Err(CoreError::sys(libc::EINVAL, "readahead"));
303 }
304
305 let count = len as libc::size_t;
306 let offset = offset as libc::off64_t;
307
308 loop {
309 let ret = unsafe { libc::syscall(readahead_syscall_number(), fd, offset, count) };
310 if ret == -1 {
311 let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
312 if code == libc::EINTR {
313 continue;
314 }
315 return Err(CoreError::sys(code, "readahead"));
316 }
317 return Ok(());
318 }
319}
320
321#[cfg(not(any(target_os = "linux", target_os = "android")))]
322fn readahead_raw(_fd: libc::c_int, _offset: u64, _len: usize) -> Result<(), CoreError> {
323 Err(CoreError::sys(libc::ENOSYS, "readahead"))
324}
325
326#[cfg(target_os = "linux")]
327#[inline(always)]
328const fn readahead_syscall_number() -> libc::c_long {
329 libc::SYS_readahead
330}
331
332#[cfg(all(target_os = "android", target_arch = "aarch64"))]
333#[inline(always)]
334const fn readahead_syscall_number() -> libc::c_long {
335 213
336}
337
338#[cfg(all(target_os = "android", target_arch = "arm"))]
339#[inline(always)]
340const fn readahead_syscall_number() -> libc::c_long {
341 225
342}
343
344#[cfg(all(target_os = "android", target_arch = "x86_64"))]
345#[inline(always)]
346const fn readahead_syscall_number() -> libc::c_long {
347 187
348}
349
350#[cfg(all(target_os = "android", target_arch = "x86"))]
351#[inline(always)]
352const fn readahead_syscall_number() -> libc::c_long {
353 225
354}
355
356const TEMP_ATTEMPTS: usize = 32;
357
358fn getrandom_bytes(buf: &mut [u8]) -> Result<(), CoreError> {
364 let mut off = 0;
365 while off < buf.len() {
366 let n = unsafe { libc::getrandom(buf[off..].as_mut_ptr() as *mut _, buf.len() - off, 0) };
369 if n < 0 {
370 if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
371 continue;
372 }
373 return Err(std::io::Error::last_os_error().into());
374 }
375 off += n as usize;
376 }
377 Ok(())
378}
379
380fn hex(bytes: &[u8]) -> String {
382 let mut out = String::with_capacity(bytes.len() * 2);
383 for b in bytes {
384 out.push_str(&format!("{b:02x}"));
385 }
386 out
387}
388
389fn cstr(s: &str) -> Result<CString, CoreError> {
390 CString::new(s).map_err(|_| CoreError::sys(libc::EINVAL, "path:nul_byte"))
391}
392
393fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> Result<RawFd, CoreError> {
394 let c = cstr(name)?;
395 let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
398 if fd < 0 {
399 Err(std::io::Error::last_os_error().into())
400 } else {
401 Ok(fd)
402 }
403}
404
405fn openat_dir(dirfd: RawFd, name: &str) -> Result<OwnedFd, CoreError> {
406 let fd = openat_raw(
407 dirfd,
408 name,
409 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
410 0,
411 )?;
412 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
414}
415
416fn walk_dir(path: &Path, create_mode: Option<u32>) -> Result<OwnedFd, CoreError> {
423 let abs = if path.is_absolute() {
424 path.to_path_buf()
425 } else {
426 std::env::current_dir()?.join(path)
427 };
428 let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
429 for comp in abs.components() {
430 use std::path::Component;
431 match comp {
432 Component::RootDir | Component::CurDir => {}
433 Component::Normal(name) => {
434 let name = name
435 .to_str()
436 .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:non_utf8"))?;
437 let next = match openat_dir(dir.as_raw_fd(), name) {
438 Ok(fd) => fd,
439 Err(e) if e.raw_os_error() == Some(libc::ENOENT) && create_mode.is_some() => {
440 let c = cstr(name)?;
441 if unsafe {
445 libc::mkdirat(
446 dir.as_raw_fd(),
447 c.as_ptr(),
448 create_mode.unwrap() as libc::mode_t,
449 )
450 } != 0
451 {
452 if std::io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST)
456 {
457 openat_dir(dir.as_raw_fd(), name)?
458 } else {
459 return Err(std::io::Error::last_os_error().into());
460 }
461 } else {
462 openat_dir(dir.as_raw_fd(), name)?
463 }
464 }
465 Err(e) => return Err(e),
466 };
467 dir = next;
468 }
469 Component::ParentDir => {
470 return Err(CoreError::sys(libc::EINVAL, "path:parent_component"));
471 }
472 Component::Prefix(_) => unreachable!("non-Windows path"),
473 }
474 }
475 Ok(dir)
476}
477
478fn open_dir_nofollow(path: &Path) -> Result<OwnedFd, CoreError> {
481 walk_dir(path, None)
482}
483
484fn fstat(fd: RawFd) -> Result<libc::stat, CoreError> {
485 let mut st: libc::stat = unsafe { std::mem::zeroed() };
486 if unsafe { libc::fstat(fd, &mut st) } != 0 {
488 Err(std::io::Error::last_os_error().into())
489 } else {
490 Ok(st)
491 }
492}
493
494fn unlink_name(dirfd: RawFd, name: &str) -> Result<(), CoreError> {
495 let c = cstr(name)?;
496 if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
499 Err(std::io::Error::last_os_error().into())
500 } else {
501 Ok(())
502 }
503}
504
505fn basename(target: &Path) -> Result<String, CoreError> {
506 target
507 .file_name()
508 .map(|n| n.to_string_lossy().into_owned())
509 .ok_or_else(|| CoreError::sys(libc::EINVAL, "path:no_file_name"))
510}
511
512fn parent_dir(target: &Path) -> &Path {
513 target
514 .parent()
515 .filter(|p| !p.as_os_str().is_empty())
516 .unwrap_or_else(|| Path::new("."))
517}
518
519fn open_parent_nofollow(target: &Path) -> Result<OwnedFd, CoreError> {
527 let dir = open_dir_nofollow(parent_dir(target))?;
528 let st = fstat(dir.as_raw_fd())?;
529 let euid = unsafe { libc::geteuid() };
530 if st.st_uid != euid {
531 return Err(CoreError::sys(libc::EACCES, "parent_dir_owner"));
532 }
533 Ok(dir)
534}
535
536struct TmpGuard {
537 dirfd: RawFd,
538 name: String,
539}
540
541impl Drop for TmpGuard {
542 fn drop(&mut self) {
543 let _ = unlink_name(self.dirfd, &self.name);
544 }
545}
546
547pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> Result<(), CoreError> {
555 let target = path.as_ref();
556 let file_name = basename(target)?;
557 let dir = open_parent_nofollow(target)?;
558 let dirfd = dir.as_raw_fd();
559
560 for attempt in 0..TEMP_ATTEMPTS {
561 let mut rand_bytes = [0u8; 16];
565 getrandom_bytes(&mut rand_bytes)?;
566 let tmp_name = format!(
567 ".{file_name}.{}.{}.{attempt:x}",
568 std::process::id(),
569 hex(&rand_bytes)
570 );
571 match openat_raw(
572 dirfd,
573 &tmp_name,
574 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
575 0o600,
576 ) {
577 Ok(raw) => {
578 let mut file = unsafe { fs::File::from_raw_fd(raw) };
581 let _guard = TmpGuard {
582 dirfd,
583 name: tmp_name.clone(),
584 };
585 file.write_all(content)?;
586 file.sync_all()?;
587 drop(file);
588 let c_tmp = cstr(&tmp_name)?;
589 let c_final = cstr(&file_name)?;
590 if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
594 return Err(std::io::Error::last_os_error().into());
595 }
596 std::mem::forget(_guard);
598 return Ok(());
599 }
600 Err(e) if e.raw_os_error() == Some(libc::EEXIST) => continue,
601 Err(e) => return Err(e),
602 }
603 }
604
605 Err(CoreError::sys(libc::EEXIST, "temp:exhausted"))
606}
607
608pub fn read_nofollow(path: impl AsRef<Path>) -> Result<String, CoreError> {
615 let target = path.as_ref();
616 let file_name = basename(target)?;
617 let dir = open_parent_nofollow(target)?;
618 let fd = openat_raw(
619 dir.as_raw_fd(),
620 &file_name,
621 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
622 0,
623 )?;
624 let mut file = unsafe { fs::File::from_raw_fd(fd) };
626 let mut content = String::new();
627 file.read_to_string(&mut content)?;
628 Ok(content)
629}
630
631pub fn open_append_nofollow(path: impl AsRef<Path>) -> Result<fs::File, CoreError> {
637 let target = path.as_ref();
638 let file_name = basename(target)?;
639 let dir = open_parent_nofollow(target)?;
640 let fd = openat_raw(
641 dir.as_raw_fd(),
642 &file_name,
643 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
644 0o644,
645 )?;
646 Ok(unsafe { fs::File::from_raw_fd(fd) })
648}
649
650pub fn remove_nofollow(path: impl AsRef<Path>) -> Result<(), CoreError> {
654 let target = path.as_ref();
655 let file_name = basename(target)?;
656 let dir = open_parent_nofollow(target)?;
657 unlink_name(dir.as_raw_fd(), &file_name)
658}
659
660pub fn ensure_state_dir(dir: impl AsRef<Path>) -> Result<OwnedFd, CoreError> {
665 let dir = dir.as_ref();
666 let fd = walk_dir(dir, Some(0o700))?;
670 let st = fstat(fd.as_raw_fd())?;
671 let euid = unsafe { libc::geteuid() };
672 if st.st_uid != euid {
673 return Err(CoreError::sys(libc::EACCES, "state_dir_owner"));
674 }
675 Ok(fd)
676}
677
678#[cfg(test)]
679mod tests {
680 #[cfg(target_os = "linux")]
681 #[test]
682 fn test_readahead_syscall_number_linux_matches_libc() {
683 assert_eq!(super::readahead_syscall_number(), libc::SYS_readahead);
684 }
685
686 #[cfg(target_os = "linux")]
687 #[test]
688 fn test_mmap_madvise_touch_past_eof_does_not_sigbus() {
689 use std::os::unix::io::{AsRawFd, FromRawFd};
690
691 let dir =
695 std::env::temp_dir().join(format!("coreshift_mmap_past_eof_{}", std::process::id()));
696 let _ = std::fs::remove_file(&dir);
697 std::fs::write(&dir, b"x").unwrap();
698
699 let f = std::fs::File::open(&dir).unwrap();
700 let fd = f.as_raw_fd();
701 let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
703 assert!(dup >= 0);
704 let owned = unsafe { std::fs::File::from_raw_fd(dup) };
705
706 let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64;
707 let result = super::mmap_madvise(owned.as_raw_fd(), 0, (page * 4) as usize, true);
708 drop(owned);
709 let _ = std::fs::remove_file(&dir);
710
711 assert!(result.is_ok(), "touch past EOF must not SIGBUS: {result:?}");
714 }
715
716 #[cfg(all(target_os = "android", target_arch = "aarch64"))]
717 #[test]
718 fn test_readahead_syscall_number_android_aarch64() {
719 assert_eq!(super::readahead_syscall_number(), 213);
720 }
721
722 #[cfg(all(target_os = "android", target_arch = "arm"))]
723 #[test]
724 fn test_readahead_syscall_number_android_arm() {
725 assert_eq!(super::readahead_syscall_number(), 225);
726 }
727
728 #[cfg(all(target_os = "android", target_arch = "x86_64"))]
729 #[test]
730 fn test_readahead_syscall_number_android_x86_64() {
731 assert_eq!(super::readahead_syscall_number(), 187);
732 }
733
734 #[cfg(all(target_os = "android", target_arch = "x86"))]
735 #[test]
736 fn test_readahead_syscall_number_android_x86() {
737 assert_eq!(super::readahead_syscall_number(), 225);
738 }
739}
740
741#[cfg(test)]
742mod safe_fs_tests {
743
744 use super::*;
745 use std::os::unix::fs::symlink;
746 use std::path::PathBuf;
747
748 fn tmpdir(name: &str) -> PathBuf {
749 let d = std::env::temp_dir().join(format!(
750 "coreshift_safe_fs_dir_{}_{name}",
751 std::process::id()
752 ));
753 let _ = fs::remove_dir_all(&d);
754 fs::create_dir_all(&d).unwrap();
755 d
756 }
757
758 #[test]
759 fn test_write_atomic_creates_regular_file() {
760 let dir = tmpdir("w");
761 let p = dir.join("out.txt");
762
763 write_atomic(&p, b"hello").unwrap();
764 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
765 assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
766 }
767
768 #[test]
769 fn test_write_atomic_replaces_existing_symlink_not_target() {
770 let dir = tmpdir("s1");
771 let target = dir.join("victim");
772 let link = dir.join("link");
773
774 fs::write(&target, b"precious").unwrap();
775 symlink(&target, &link).unwrap();
776
777 write_atomic(&link, b"new").unwrap();
780
781 assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
782 assert_eq!(fs::read_to_string(&link).unwrap(), "new");
783 assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
784 }
785
786 #[test]
787 fn test_read_nofollow_refuses_symlink() {
788 let dir = tmpdir("r");
789 let target = dir.join("victim2");
790 let link = dir.join("link2");
791
792 fs::write(&target, b"secret").unwrap();
793 symlink(&target, &link).unwrap();
794
795 assert_eq!(read_nofollow(&target).unwrap(), "secret");
796 assert!(read_nofollow(&link).is_err());
797 }
798
799 #[test]
800 fn test_path_fingerprint_nofollow_refuses_symlink() {
801 let dir = tmpdir("fp");
802 let target = dir.join("target_fp");
803 let link = dir.join("link_fp");
804
805 fs::write(&target, b"secret").unwrap();
806 symlink(&target, &link).unwrap();
807
808 assert!(path_fingerprint_nofollow(&target).is_ok());
810 assert!(path_fingerprint_nofollow(&link).is_err());
811 let parent_link = dir.join("plink");
813 symlink(&dir, &parent_link).unwrap();
814 assert!(path_fingerprint_nofollow(&parent_link.join("target_fp")).is_err());
815 }
816
817 #[test]
818 fn test_open_append_nofollow_refuses_symlink() {
819 let dir = tmpdir("a");
820 let target = dir.join("target3");
821 let link = dir.join("link3");
822
823 fs::write(&target, b"x").unwrap();
824 symlink(&target, &link).unwrap();
825
826 assert!(open_append_nofollow(&target).is_ok());
828 assert!(open_append_nofollow(&link).is_err());
830
831 let _ = fs::remove_file(&target);
832 let _ = fs::remove_file(&link);
833 }
834
835 #[test]
836 fn test_write_atomic_refuses_symlinked_parent() {
837 let dir = tmpdir("parent_symlink");
841 let elsewhere = tmpdir("parent_dest");
842 let link = dir.join("coreshift");
843 symlink(&elsewhere, &link).unwrap();
844
845 assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
846 assert!(!elsewhere.join("payload.txt").exists());
847 assert!(!link.join("payload.txt").exists());
848 }
849
850 #[test]
851 fn test_read_nofollow_refuses_symlinked_parent() {
852 let dir = tmpdir("read_parent_symlink");
853 let elsewhere = tmpdir("read_parent_dest");
854 fs::write(elsewhere.join("conf"), b"injected").unwrap();
855 let link = dir.join("coreshift");
856 symlink(&elsewhere, &link).unwrap();
857
858 assert!(read_nofollow(link.join("conf")).is_err());
859 }
860
861 #[test]
862 fn test_open_append_nofollow_refuses_symlinked_parent() {
863 let dir = tmpdir("append_parent_symlink");
864 let elsewhere = tmpdir("append_parent_dest");
865 let link = dir.join("coreshift");
866 symlink(&elsewhere, &link).unwrap();
867
868 assert!(open_append_nofollow(link.join("daemon.log")).is_err());
869 assert!(!elsewhere.join("daemon.log").exists());
870 }
871
872 #[test]
873 fn test_ensure_state_dir_refuses_symlink() {
874 let dir = tmpdir("state_symlink");
875 let elsewhere = tmpdir("state_dest");
876 let link = dir.join("state");
877 symlink(&elsewhere, &link).unwrap();
878
879 assert!(ensure_state_dir(&link).is_err());
880 let real = tmpdir("state_real");
882 assert!(ensure_state_dir(&real).is_ok());
883 }
884
885 #[test]
886 fn test_remove_nofollow_removes_entry_not_target() {
887 let dir = tmpdir("unlink");
888 let target = dir.join("victim4");
889 let link = dir.join("link4");
890 fs::write(&target, b"keep").unwrap();
891 symlink(&target, &link).unwrap();
892
893 remove_nofollow(&link).unwrap();
894 assert!(!link.exists());
895 assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
896 }
897
898 #[test]
899 fn test_write_atomic_requires_parent_to_exist() {
900 let dir = tmpdir("missing_parent");
901 let p = dir.join("nope").join("file.txt");
902
903 assert!(write_atomic(&p, b"x").is_err());
904 assert!(!p.exists());
905 }
906
907 #[test]
908 fn test_ops_refuse_foreign_owned_parent() {
909 if unsafe { libc::geteuid() } != 0 {
914 return;
915 }
916 let dir = tmpdir("foreign_owner");
917 let path = dir.join("f");
918 let owned = cstr(&dir.to_string_lossy()).unwrap();
919 assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
921
922 assert!(write_atomic(&path, b"x").is_err());
923 assert!(read_nofollow(&path).is_err());
924 assert!(open_append_nofollow(&path).is_err());
925 assert!(remove_nofollow(&path).is_err());
926 assert!(ensure_state_dir(&dir).is_err());
927 assert!(!path.exists());
928 }
929
930 fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
931 let prefix = format!(".{file_name}.");
932 fs::read_dir(dir)
933 .map(|rd| {
934 rd.filter_map(|e| e.ok())
935 .filter_map(|e| e.file_name().to_str().map(str::to_owned))
936 .filter(|n| n.starts_with(&prefix))
937 .collect()
938 })
939 .unwrap_or_default()
940 }
941
942 #[test]
943 fn test_write_atomic_cleans_temp_on_rename_failure() {
944 let dir = tmpdir("rename_fail");
948 let dest = dir.join("dest");
949 fs::create_dir_all(&dest).unwrap();
950 fs::write(dest.join("keep"), b"x").unwrap();
951
952 assert!(write_atomic(&dest, b"boom").is_err());
953 assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
954 assert!(
955 temp_leftovers(&dir, "dest").is_empty(),
956 "temp file must be cleaned up"
957 );
958 }
959
960 #[test]
961 fn test_write_atomic_leaves_no_temp_on_success() {
962 let dir = tmpdir("no_temp_success");
963 let p = dir.join("out.txt");
964
965 write_atomic(&p, b"hello").unwrap();
966 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
967 assert!(
968 temp_leftovers(&dir, "out.txt").is_empty(),
969 "no temp left behind"
970 );
971 }
972
973 #[test]
974 fn test_write_atomic_retries_when_temp_name_exists() {
975 let dir = tmpdir("temp_collision");
976 let p = dir.join("out.txt");
977 let pid = std::process::id();
978 let collided = dir.join(format!(".out.txt.{pid}.0"));
979 fs::write(&collided, b"not mine").unwrap();
980
981 write_atomic(&p, b"hello").unwrap();
982 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
983 assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
986 let leftovers = temp_leftovers(&dir, "out.txt");
987 assert_eq!(
988 leftovers.len(),
989 1,
990 "only the pre-existing colliding temp remains"
991 );
992 assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
993 }
994
995 #[test]
996 fn test_ops_refuse_foreign_owned_writable_parent() {
997 use std::os::unix::fs::MetadataExt;
1003 let euid = unsafe { libc::geteuid() };
1004 if euid == 0 {
1005 return;
1006 }
1007 let tmp = std::env::temp_dir();
1008 let meta = match fs::symlink_metadata(&tmp) {
1009 Ok(m) => m,
1010 Err(_) => return,
1011 };
1012 if meta.uid() == euid {
1013 return; }
1015 if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
1016 return; }
1018 let p = tmp.join(format!(
1019 "coreshift_fs_foreign_{}_{}",
1020 std::process::id(),
1021 "out"
1022 ));
1023 let _ = fs::remove_file(&p);
1024 fs::write(&p, b"probe").unwrap();
1025
1026 assert!(read_nofollow(&p).is_err());
1027 assert!(open_append_nofollow(&p).is_err());
1028 assert!(write_atomic(&p, b"boom").is_err());
1029 assert!(remove_nofollow(&p).is_err());
1030 assert!(ensure_state_dir(&tmp).is_err());
1031 assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
1032 let _ = fs::remove_file(&p);
1033 }
1034
1035 #[test]
1036 fn test_ensure_state_dir_creates_fresh_dir() {
1037 let base = tmpdir("fresh_base");
1038 let nested = base.join("a").join("b").join("state");
1039
1040 let fd = ensure_state_dir(&nested).unwrap();
1041 assert!(nested.is_dir());
1042 drop(fd);
1043 assert!(ensure_state_dir(&nested).is_ok());
1044 }
1045
1046 #[test]
1047 fn test_open_append_nofollow_refuses_dangling_symlink() {
1048 let dir = tmpdir("dangling_append");
1049 let missing = dir.join("not_there.txt");
1050 let link = dir.join("linkd");
1051 symlink(&missing, &link).unwrap();
1052
1053 assert!(open_append_nofollow(&link).is_err());
1054 assert!(
1055 !missing.exists(),
1056 "must not create the target through a dangling link"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_read_nofollow_refuses_dangling_symlink() {
1062 let dir = tmpdir("dangling_read");
1063 let missing = dir.join("not_there2.txt");
1064 let link = dir.join("linkd2");
1065 symlink(&missing, &link).unwrap();
1066
1067 assert!(read_nofollow(&link).is_err());
1068 assert!(!missing.exists());
1069 }
1070
1071 #[test]
1072 fn test_ops_refuse_regular_file_parent() {
1073 let dir = tmpdir("regfile_parent");
1076 let f = dir.join("notadir");
1077 fs::write(&f, b"x").unwrap();
1078
1079 assert!(write_atomic(f.join("out"), b"y").is_err());
1080 assert!(read_nofollow(f.join("out")).is_err());
1081 assert!(open_append_nofollow(f.join("out")).is_err());
1082 assert!(remove_nofollow(f.join("out")).is_err());
1083 assert!(ensure_state_dir(&f).is_err());
1084 assert_eq!(fs::read_to_string(&f).unwrap(), "x");
1085 }
1086}