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(dir.as_raw_fd(), c.as_ptr(), create_mode.unwrap() as libc::mode_t)
84 } != 0
85 {
86 if io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST) {
90 openat_dir(dir.as_raw_fd(), name)?
91 } else {
92 return Err(io::Error::last_os_error());
93 }
94 } else {
95 openat_dir(dir.as_raw_fd(), name)?
96 }
97 }
98 Err(e) => return Err(e),
99 };
100 dir = next;
101 }
102 Component::ParentDir => {
103 return Err(io::Error::new(
104 io::ErrorKind::InvalidInput,
105 ".. in state path not allowed",
106 ))
107 }
108 Component::Prefix(_) => unreachable!("non-Windows path"),
109 }
110 }
111 Ok(dir)
112}
113
114fn open_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
117 walk_dir(path, None)
118}
119
120fn fstat(fd: RawFd) -> io::Result<libc::stat> {
121 let mut st: libc::stat = unsafe { std::mem::zeroed() };
122 if unsafe { libc::fstat(fd, &mut st) } != 0 {
124 Err(io::Error::last_os_error())
125 } else {
126 Ok(st)
127 }
128}
129
130fn unlink_name(dirfd: RawFd, name: &str) -> io::Result<()> {
131 let c = cstr(name)?;
132 if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
135 Err(io::Error::last_os_error())
136 } else {
137 Ok(())
138 }
139}
140
141fn basename(target: &Path) -> io::Result<String> {
142 target
143 .file_name()
144 .map(|n| n.to_string_lossy().into_owned())
145 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))
146}
147
148fn parent_dir(target: &Path) -> &Path {
149 target
150 .parent()
151 .filter(|p| !p.as_os_str().is_empty())
152 .unwrap_or_else(|| Path::new("."))
153}
154
155fn open_parent_nofollow(target: &Path) -> io::Result<OwnedFd> {
163 let dir = open_dir_nofollow(parent_dir(target))?;
164 let st = fstat(dir.as_raw_fd())?;
165 let euid = unsafe { libc::geteuid() };
166 if st.st_uid != euid {
167 return Err(io::Error::new(
168 io::ErrorKind::PermissionDenied,
169 format!(
170 "parent directory is owned by uid {}, not euid {}",
171 st.st_uid, euid
172 ),
173 ));
174 }
175 Ok(dir)
176}
177
178struct TmpGuard {
179 dirfd: RawFd,
180 name: String,
181}
182
183impl Drop for TmpGuard {
184 fn drop(&mut self) {
185 let _ = unlink_name(self.dirfd, &self.name);
186 }
187}
188
189pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> io::Result<()> {
197 let target = path.as_ref();
198 let file_name = basename(target)?;
199 let dir = open_parent_nofollow(target)?;
200 let dirfd = dir.as_raw_fd();
201
202 for attempt in 0..TEMP_ATTEMPTS {
203 let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
204 match openat_raw(
205 dirfd,
206 &tmp_name,
207 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
208 0o600,
209 ) {
210 Ok(raw) => {
211 let mut file = unsafe { fs::File::from_raw_fd(raw) };
214 let _guard = TmpGuard {
215 dirfd,
216 name: tmp_name.clone(),
217 };
218 file.write_all(content)?;
219 file.sync_all()?;
220 drop(file);
221 let c_tmp = cstr(&tmp_name)?;
222 let c_final = cstr(&file_name)?;
223 if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
227 return Err(io::Error::last_os_error());
228 }
229 std::mem::forget(_guard);
231 return Ok(());
232 }
233 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
234 Err(e) => return Err(e),
235 }
236 }
237
238 Err(io::Error::new(
239 io::ErrorKind::AlreadyExists,
240 "could not reserve a unique temp name",
241 ))
242}
243
244pub fn read_nofollow(path: impl AsRef<Path>) -> io::Result<String> {
251 let target = path.as_ref();
252 let file_name = basename(target)?;
253 let dir = open_parent_nofollow(target)?;
254 let fd = openat_raw(
255 dir.as_raw_fd(),
256 &file_name,
257 libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
258 0,
259 )?;
260 let mut file = unsafe { fs::File::from_raw_fd(fd) };
262 let mut content = String::new();
263 file.read_to_string(&mut content)?;
264 Ok(content)
265}
266
267pub fn open_append_nofollow(path: impl AsRef<Path>) -> io::Result<fs::File> {
273 let target = path.as_ref();
274 let file_name = basename(target)?;
275 let dir = open_parent_nofollow(target)?;
276 let fd = openat_raw(
277 dir.as_raw_fd(),
278 &file_name,
279 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
280 0o644,
281 )?;
282 Ok(unsafe { fs::File::from_raw_fd(fd) })
284}
285
286pub fn remove_nofollow(path: impl AsRef<Path>) -> io::Result<()> {
290 let target = path.as_ref();
291 let file_name = basename(target)?;
292 let dir = open_parent_nofollow(target)?;
293 unlink_name(dir.as_raw_fd(), &file_name)
294}
295
296pub fn ensure_state_dir(dir: impl AsRef<Path>) -> io::Result<OwnedFd> {
301 let dir = dir.as_ref();
302 let fd = walk_dir(dir, Some(0o700))?;
306 let st = fstat(fd.as_raw_fd())?;
307 let euid = unsafe { libc::geteuid() };
308 if st.st_uid != euid {
309 return Err(io::Error::new(
310 io::ErrorKind::PermissionDenied,
311 format!("state directory is owned by uid {}, not euid {}", st.st_uid, euid),
312 ));
313 }
314 Ok(fd)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::os::unix::fs::symlink;
321 use std::path::PathBuf;
322
323 fn tmpdir(name: &str) -> PathBuf {
324 let d = std::env::temp_dir().join(format!(
325 "coreshift_safe_fs_dir_{}_{name}",
326 std::process::id()
327 ));
328 let _ = fs::remove_dir_all(&d);
329 fs::create_dir_all(&d).unwrap();
330 d
331 }
332
333 #[test]
334 fn test_write_atomic_creates_regular_file() {
335 let dir = tmpdir("w");
336 let p = dir.join("out.txt");
337
338 write_atomic(&p, b"hello").unwrap();
339 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
340 assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
341 }
342
343 #[test]
344 fn test_write_atomic_replaces_existing_symlink_not_target() {
345 let dir = tmpdir("s1");
346 let target = dir.join("victim");
347 let link = dir.join("link");
348
349 fs::write(&target, b"precious").unwrap();
350 symlink(&target, &link).unwrap();
351
352 write_atomic(&link, b"new").unwrap();
355
356 assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
357 assert_eq!(fs::read_to_string(&link).unwrap(), "new");
358 assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
359 }
360
361 #[test]
362 fn test_read_nofollow_refuses_symlink() {
363 let dir = tmpdir("r");
364 let target = dir.join("victim2");
365 let link = dir.join("link2");
366
367 fs::write(&target, b"secret").unwrap();
368 symlink(&target, &link).unwrap();
369
370 assert_eq!(read_nofollow(&target).unwrap(), "secret");
371 assert!(read_nofollow(&link).is_err());
372 }
373
374 #[test]
375 fn test_open_append_nofollow_refuses_symlink() {
376 let dir = tmpdir("a");
377 let target = dir.join("target3");
378 let link = dir.join("link3");
379
380 fs::write(&target, b"x").unwrap();
381 symlink(&target, &link).unwrap();
382
383 assert!(open_append_nofollow(&target).is_ok());
385 assert!(open_append_nofollow(&link).is_err());
387
388 let _ = fs::remove_file(&target);
389 let _ = fs::remove_file(&link);
390 }
391
392 #[test]
393 fn test_write_atomic_refuses_symlinked_parent() {
394 let dir = tmpdir("parent_symlink");
398 let elsewhere = tmpdir("parent_dest");
399 let link = dir.join("coreshift");
400 symlink(&elsewhere, &link).unwrap();
401
402 assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
403 assert!(!elsewhere.join("payload.txt").exists());
404 assert!(!link.join("payload.txt").exists());
405 }
406
407 #[test]
408 fn test_read_nofollow_refuses_symlinked_parent() {
409 let dir = tmpdir("read_parent_symlink");
410 let elsewhere = tmpdir("read_parent_dest");
411 fs::write(elsewhere.join("conf"), b"injected").unwrap();
412 let link = dir.join("coreshift");
413 symlink(&elsewhere, &link).unwrap();
414
415 assert!(read_nofollow(link.join("conf")).is_err());
416 }
417
418 #[test]
419 fn test_open_append_nofollow_refuses_symlinked_parent() {
420 let dir = tmpdir("append_parent_symlink");
421 let elsewhere = tmpdir("append_parent_dest");
422 let link = dir.join("coreshift");
423 symlink(&elsewhere, &link).unwrap();
424
425 assert!(open_append_nofollow(link.join("daemon.log")).is_err());
426 assert!(!elsewhere.join("daemon.log").exists());
427 }
428
429 #[test]
430 fn test_ensure_state_dir_refuses_symlink() {
431 let dir = tmpdir("state_symlink");
432 let elsewhere = tmpdir("state_dest");
433 let link = dir.join("state");
434 symlink(&elsewhere, &link).unwrap();
435
436 assert!(ensure_state_dir(&link).is_err());
437 let real = tmpdir("state_real");
439 assert!(ensure_state_dir(&real).is_ok());
440 }
441
442 #[test]
443 fn test_remove_nofollow_removes_entry_not_target() {
444 let dir = tmpdir("unlink");
445 let target = dir.join("victim4");
446 let link = dir.join("link4");
447 fs::write(&target, b"keep").unwrap();
448 symlink(&target, &link).unwrap();
449
450 remove_nofollow(&link).unwrap();
451 assert!(!link.exists());
452 assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
453 }
454
455 #[test]
456 fn test_write_atomic_requires_parent_to_exist() {
457 let dir = tmpdir("missing_parent");
458 let p = dir.join("nope").join("file.txt");
459
460 assert!(write_atomic(&p, b"x").is_err());
461 assert!(!p.exists());
462 }
463
464 #[test]
465 fn test_ops_refuse_foreign_owned_parent() {
466 if unsafe { libc::geteuid() } != 0 {
471 return;
472 }
473 let dir = tmpdir("foreign_owner");
474 let path = dir.join("f");
475 let owned = cstr(&dir.to_string_lossy()).unwrap();
476 assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
478
479 assert!(write_atomic(&path, b"x").is_err());
480 assert!(read_nofollow(&path).is_err());
481 assert!(open_append_nofollow(&path).is_err());
482 assert!(remove_nofollow(&path).is_err());
483 assert!(ensure_state_dir(&dir).is_err());
484 assert!(!path.exists());
485 }
486
487 fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
488 let prefix = format!(".{file_name}.");
489 fs::read_dir(dir)
490 .map(|rd| {
491 rd.filter_map(|e| e.ok())
492 .filter_map(|e| e.file_name().to_str().map(str::to_owned))
493 .filter(|n| n.starts_with(&prefix))
494 .collect()
495 })
496 .unwrap_or_default()
497 }
498
499 #[test]
500 fn test_write_atomic_cleans_temp_on_rename_failure() {
501 let dir = tmpdir("rename_fail");
505 let dest = dir.join("dest");
506 fs::create_dir_all(&dest).unwrap();
507 fs::write(dest.join("keep"), b"x").unwrap();
508
509 assert!(write_atomic(&dest, b"boom").is_err());
510 assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
511 assert!(temp_leftovers(&dir, "dest").is_empty(), "temp file must be cleaned up");
512 }
513
514 #[test]
515 fn test_write_atomic_leaves_no_temp_on_success() {
516 let dir = tmpdir("no_temp_success");
517 let p = dir.join("out.txt");
518
519 write_atomic(&p, b"hello").unwrap();
520 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
521 assert!(temp_leftovers(&dir, "out.txt").is_empty(), "no temp left behind");
522 }
523
524 #[test]
525 fn test_write_atomic_retries_when_temp_name_exists() {
526 let dir = tmpdir("temp_collision");
527 let p = dir.join("out.txt");
528 let pid = std::process::id();
529 let collided = dir.join(format!(".out.txt.{pid}.0"));
530 fs::write(&collided, b"not mine").unwrap();
531
532 write_atomic(&p, b"hello").unwrap();
533 assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
534 assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
537 let leftovers = temp_leftovers(&dir, "out.txt");
538 assert_eq!(leftovers.len(), 1, "only the pre-existing colliding temp remains");
539 assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
540 }
541
542 #[test]
543 fn test_ops_refuse_foreign_owned_writable_parent() {
544 use std::os::unix::fs::MetadataExt;
550 let euid = unsafe { libc::geteuid() };
551 if euid == 0 {
552 return;
553 }
554 let tmp = std::env::temp_dir();
555 let meta = match fs::symlink_metadata(&tmp) {
556 Ok(m) => m,
557 Err(_) => return,
558 };
559 if meta.uid() == euid {
560 return; }
562 if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
563 return; }
565 let p = tmp.join(format!("coreshift_fs_foreign_{}_{}", std::process::id(), "out"));
566 let _ = fs::remove_file(&p);
567 fs::write(&p, b"probe").unwrap();
568
569 assert!(read_nofollow(&p).is_err());
570 assert!(open_append_nofollow(&p).is_err());
571 assert!(write_atomic(&p, b"boom").is_err());
572 assert!(remove_nofollow(&p).is_err());
573 assert!(ensure_state_dir(&tmp).is_err());
574 assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
575 let _ = fs::remove_file(&p);
576 }
577
578 #[test]
579 fn test_ensure_state_dir_creates_fresh_dir() {
580 let base = tmpdir("fresh_base");
581 let nested = base.join("a").join("b").join("state");
582
583 let fd = ensure_state_dir(&nested).unwrap();
584 assert!(nested.is_dir());
585 drop(fd);
586 assert!(ensure_state_dir(&nested).is_ok());
587 }
588
589 #[test]
590 fn test_open_append_nofollow_refuses_dangling_symlink() {
591 let dir = tmpdir("dangling_append");
592 let missing = dir.join("not_there.txt");
593 let link = dir.join("linkd");
594 symlink(&missing, &link).unwrap();
595
596 assert!(open_append_nofollow(&link).is_err());
597 assert!(!missing.exists(), "must not create the target through a dangling link");
598 }
599
600 #[test]
601 fn test_read_nofollow_refuses_dangling_symlink() {
602 let dir = tmpdir("dangling_read");
603 let missing = dir.join("not_there2.txt");
604 let link = dir.join("linkd2");
605 symlink(&missing, &link).unwrap();
606
607 assert!(read_nofollow(&link).is_err());
608 assert!(!missing.exists());
609 }
610
611 #[test]
612 fn test_ops_refuse_regular_file_parent() {
613 let dir = tmpdir("regfile_parent");
616 let f = dir.join("notadir");
617 fs::write(&f, b"x").unwrap();
618
619 assert!(write_atomic(f.join("out"), b"y").is_err());
620 assert!(read_nofollow(f.join("out")).is_err());
621 assert!(open_append_nofollow(f.join("out")).is_err());
622 assert!(remove_nofollow(f.join("out")).is_err());
623 assert!(ensure_state_dir(&f).is_err());
624 assert_eq!(fs::read_to_string(&f).unwrap(), "x");
625 }
626}