use std::ffi::CString;
use std::fs;
use std::io::{self, Read, Write};
use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::path::Path;
const TEMP_ATTEMPTS: usize = 32;
fn cstr(s: &str) -> io::Result<CString> {
CString::new(s)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL byte in path component"))
}
fn openat_raw(dirfd: RawFd, name: &str, flags: i32, mode: u32) -> io::Result<RawFd> {
let c = cstr(name)?;
let fd = unsafe { libc::openat(dirfd, c.as_ptr(), flags, mode as libc::c_uint) };
if fd < 0 {
Err(io::Error::last_os_error())
} else {
Ok(fd)
}
}
fn openat_dir(dirfd: RawFd, name: &str) -> io::Result<OwnedFd> {
let fd = openat_raw(
dirfd,
name,
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0,
)?;
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
fn walk_dir(path: &Path, create_mode: Option<u32>) -> io::Result<OwnedFd> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut dir = openat_dir(libc::AT_FDCWD, "/")?;
for comp in abs.components() {
use std::path::Component;
match comp {
Component::RootDir | Component::CurDir => {}
Component::Normal(name) => {
let name = name.to_str().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 path component")
})?;
let next = match openat_dir(dir.as_raw_fd(), name) {
Ok(fd) => fd,
Err(e) if e.kind() == io::ErrorKind::NotFound && create_mode.is_some() => {
let c = cstr(name)?;
if unsafe {
libc::mkdirat(dir.as_raw_fd(), c.as_ptr(), create_mode.unwrap() as libc::mode_t)
} != 0
{
if io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST) {
openat_dir(dir.as_raw_fd(), name)?
} else {
return Err(io::Error::last_os_error());
}
} else {
openat_dir(dir.as_raw_fd(), name)?
}
}
Err(e) => return Err(e),
};
dir = next;
}
Component::ParentDir => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
".. in state path not allowed",
))
}
Component::Prefix(_) => unreachable!("non-Windows path"),
}
}
Ok(dir)
}
fn open_dir_nofollow(path: &Path) -> io::Result<OwnedFd> {
walk_dir(path, None)
}
fn fstat(fd: RawFd) -> io::Result<libc::stat> {
let mut st: libc::stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstat(fd, &mut st) } != 0 {
Err(io::Error::last_os_error())
} else {
Ok(st)
}
}
fn unlink_name(dirfd: RawFd, name: &str) -> io::Result<()> {
let c = cstr(name)?;
if unsafe { libc::unlinkat(dirfd, c.as_ptr(), 0) } != 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
fn basename(target: &Path) -> io::Result<String> {
target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))
}
fn parent_dir(target: &Path) -> &Path {
target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn open_parent_nofollow(target: &Path) -> io::Result<OwnedFd> {
let dir = open_dir_nofollow(parent_dir(target))?;
let st = fstat(dir.as_raw_fd())?;
let euid = unsafe { libc::geteuid() };
if st.st_uid != euid {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"parent directory is owned by uid {}, not euid {}",
st.st_uid, euid
),
));
}
Ok(dir)
}
struct TmpGuard {
dirfd: RawFd,
name: String,
}
impl Drop for TmpGuard {
fn drop(&mut self) {
let _ = unlink_name(self.dirfd, &self.name);
}
}
pub fn write_atomic(path: impl AsRef<Path>, content: &[u8]) -> io::Result<()> {
let target = path.as_ref();
let file_name = basename(target)?;
let dir = open_parent_nofollow(target)?;
let dirfd = dir.as_raw_fd();
for attempt in 0..TEMP_ATTEMPTS {
let tmp_name = format!(".{file_name}.{}.{attempt}", std::process::id());
match openat_raw(
dirfd,
&tmp_name,
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
0o600,
) {
Ok(raw) => {
let mut file = unsafe { fs::File::from_raw_fd(raw) };
let _guard = TmpGuard {
dirfd,
name: tmp_name.clone(),
};
file.write_all(content)?;
file.sync_all()?;
drop(file);
let c_tmp = cstr(&tmp_name)?;
let c_final = cstr(&file_name)?;
if unsafe { libc::renameat(dirfd, c_tmp.as_ptr(), dirfd, c_final.as_ptr()) } != 0 {
return Err(io::Error::last_os_error());
}
std::mem::forget(_guard);
return Ok(());
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not reserve a unique temp name",
))
}
pub fn read_nofollow(path: impl AsRef<Path>) -> io::Result<String> {
let target = path.as_ref();
let file_name = basename(target)?;
let dir = open_parent_nofollow(target)?;
let fd = openat_raw(
dir.as_raw_fd(),
&file_name,
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0,
)?;
let mut file = unsafe { fs::File::from_raw_fd(fd) };
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
pub fn open_append_nofollow(path: impl AsRef<Path>) -> io::Result<fs::File> {
let target = path.as_ref();
let file_name = basename(target)?;
let dir = open_parent_nofollow(target)?;
let fd = openat_raw(
dir.as_raw_fd(),
&file_name,
libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0o644,
)?;
Ok(unsafe { fs::File::from_raw_fd(fd) })
}
pub fn remove_nofollow(path: impl AsRef<Path>) -> io::Result<()> {
let target = path.as_ref();
let file_name = basename(target)?;
let dir = open_parent_nofollow(target)?;
unlink_name(dir.as_raw_fd(), &file_name)
}
pub fn ensure_state_dir(dir: impl AsRef<Path>) -> io::Result<OwnedFd> {
let dir = dir.as_ref();
let fd = walk_dir(dir, Some(0o700))?;
let st = fstat(fd.as_raw_fd())?;
let euid = unsafe { libc::geteuid() };
if st.st_uid != euid {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("state directory is owned by uid {}, not euid {}", st.st_uid, euid),
));
}
Ok(fd)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use std::path::PathBuf;
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"coreshift_safe_fs_dir_{}_{name}",
std::process::id()
));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn test_write_atomic_creates_regular_file() {
let dir = tmpdir("w");
let p = dir.join("out.txt");
write_atomic(&p, b"hello").unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
assert!(fs::symlink_metadata(&p).unwrap().file_type().is_file());
}
#[test]
fn test_write_atomic_replaces_existing_symlink_not_target() {
let dir = tmpdir("s1");
let target = dir.join("victim");
let link = dir.join("link");
fs::write(&target, b"precious").unwrap();
symlink(&target, &link).unwrap();
write_atomic(&link, b"new").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "precious");
assert_eq!(fs::read_to_string(&link).unwrap(), "new");
assert!(fs::symlink_metadata(&link).unwrap().file_type().is_file());
}
#[test]
fn test_read_nofollow_refuses_symlink() {
let dir = tmpdir("r");
let target = dir.join("victim2");
let link = dir.join("link2");
fs::write(&target, b"secret").unwrap();
symlink(&target, &link).unwrap();
assert_eq!(read_nofollow(&target).unwrap(), "secret");
assert!(read_nofollow(&link).is_err());
}
#[test]
fn test_open_append_nofollow_refuses_symlink() {
let dir = tmpdir("a");
let target = dir.join("target3");
let link = dir.join("link3");
fs::write(&target, b"x").unwrap();
symlink(&target, &link).unwrap();
assert!(open_append_nofollow(&target).is_ok());
assert!(open_append_nofollow(&link).is_err());
let _ = fs::remove_file(&target);
let _ = fs::remove_file(&link);
}
#[test]
fn test_write_atomic_refuses_symlinked_parent() {
let dir = tmpdir("parent_symlink");
let elsewhere = tmpdir("parent_dest");
let link = dir.join("coreshift");
symlink(&elsewhere, &link).unwrap();
assert!(write_atomic(link.join("payload.txt"), b"boom").is_err());
assert!(!elsewhere.join("payload.txt").exists());
assert!(!link.join("payload.txt").exists());
}
#[test]
fn test_read_nofollow_refuses_symlinked_parent() {
let dir = tmpdir("read_parent_symlink");
let elsewhere = tmpdir("read_parent_dest");
fs::write(elsewhere.join("conf"), b"injected").unwrap();
let link = dir.join("coreshift");
symlink(&elsewhere, &link).unwrap();
assert!(read_nofollow(link.join("conf")).is_err());
}
#[test]
fn test_open_append_nofollow_refuses_symlinked_parent() {
let dir = tmpdir("append_parent_symlink");
let elsewhere = tmpdir("append_parent_dest");
let link = dir.join("coreshift");
symlink(&elsewhere, &link).unwrap();
assert!(open_append_nofollow(link.join("daemon.log")).is_err());
assert!(!elsewhere.join("daemon.log").exists());
}
#[test]
fn test_ensure_state_dir_refuses_symlink() {
let dir = tmpdir("state_symlink");
let elsewhere = tmpdir("state_dest");
let link = dir.join("state");
symlink(&elsewhere, &link).unwrap();
assert!(ensure_state_dir(&link).is_err());
let real = tmpdir("state_real");
assert!(ensure_state_dir(&real).is_ok());
}
#[test]
fn test_remove_nofollow_removes_entry_not_target() {
let dir = tmpdir("unlink");
let target = dir.join("victim4");
let link = dir.join("link4");
fs::write(&target, b"keep").unwrap();
symlink(&target, &link).unwrap();
remove_nofollow(&link).unwrap();
assert!(!link.exists());
assert_eq!(fs::read_to_string(&target).unwrap(), "keep");
}
#[test]
fn test_write_atomic_requires_parent_to_exist() {
let dir = tmpdir("missing_parent");
let p = dir.join("nope").join("file.txt");
assert!(write_atomic(&p, b"x").is_err());
assert!(!p.exists());
}
#[test]
fn test_ops_refuse_foreign_owned_parent() {
if unsafe { libc::geteuid() } != 0 {
return;
}
let dir = tmpdir("foreign_owner");
let path = dir.join("f");
let owned = cstr(&dir.to_string_lossy()).unwrap();
assert_eq!(unsafe { libc::chown(owned.as_ptr(), 65534, 65534) }, 0);
assert!(write_atomic(&path, b"x").is_err());
assert!(read_nofollow(&path).is_err());
assert!(open_append_nofollow(&path).is_err());
assert!(remove_nofollow(&path).is_err());
assert!(ensure_state_dir(&dir).is_err());
assert!(!path.exists());
}
fn temp_leftovers(dir: &Path, file_name: &str) -> Vec<String> {
let prefix = format!(".{file_name}.");
fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.filter(|n| n.starts_with(&prefix))
.collect()
})
.unwrap_or_default()
}
#[test]
fn test_write_atomic_cleans_temp_on_rename_failure() {
let dir = tmpdir("rename_fail");
let dest = dir.join("dest");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("keep"), b"x").unwrap();
assert!(write_atomic(&dest, b"boom").is_err());
assert_eq!(fs::read_to_string(dest.join("keep")).unwrap(), "x");
assert!(temp_leftovers(&dir, "dest").is_empty(), "temp file must be cleaned up");
}
#[test]
fn test_write_atomic_leaves_no_temp_on_success() {
let dir = tmpdir("no_temp_success");
let p = dir.join("out.txt");
write_atomic(&p, b"hello").unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
assert!(temp_leftovers(&dir, "out.txt").is_empty(), "no temp left behind");
}
#[test]
fn test_write_atomic_retries_when_temp_name_exists() {
let dir = tmpdir("temp_collision");
let p = dir.join("out.txt");
let pid = std::process::id();
let collided = dir.join(format!(".out.txt.{pid}.0"));
fs::write(&collided, b"not mine").unwrap();
write_atomic(&p, b"hello").unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), "hello");
assert_eq!(fs::read_to_string(&collided).unwrap(), "not mine");
let leftovers = temp_leftovers(&dir, "out.txt");
assert_eq!(leftovers.len(), 1, "only the pre-existing colliding temp remains");
assert_eq!(leftovers[0], format!(".out.txt.{pid}.0"));
}
#[test]
fn test_ops_refuse_foreign_owned_writable_parent() {
use std::os::unix::fs::MetadataExt;
let euid = unsafe { libc::geteuid() };
if euid == 0 {
return;
}
let tmp = std::env::temp_dir();
let meta = match fs::symlink_metadata(&tmp) {
Ok(m) => m,
Err(_) => return,
};
if meta.uid() == euid {
return; }
if meta.mode() & 0o002 == 0 && meta.mode() & 0o020 == 0 {
return; }
let p = tmp.join(format!("coreshift_fs_foreign_{}_{}", std::process::id(), "out"));
let _ = fs::remove_file(&p);
fs::write(&p, b"probe").unwrap();
assert!(read_nofollow(&p).is_err());
assert!(open_append_nofollow(&p).is_err());
assert!(write_atomic(&p, b"boom").is_err());
assert!(remove_nofollow(&p).is_err());
assert!(ensure_state_dir(&tmp).is_err());
assert_eq!(fs::read_to_string(&p).unwrap(), "probe");
let _ = fs::remove_file(&p);
}
#[test]
fn test_ensure_state_dir_creates_fresh_dir() {
let base = tmpdir("fresh_base");
let nested = base.join("a").join("b").join("state");
let fd = ensure_state_dir(&nested).unwrap();
assert!(nested.is_dir());
drop(fd);
assert!(ensure_state_dir(&nested).is_ok());
}
#[test]
fn test_open_append_nofollow_refuses_dangling_symlink() {
let dir = tmpdir("dangling_append");
let missing = dir.join("not_there.txt");
let link = dir.join("linkd");
symlink(&missing, &link).unwrap();
assert!(open_append_nofollow(&link).is_err());
assert!(!missing.exists(), "must not create the target through a dangling link");
}
#[test]
fn test_read_nofollow_refuses_dangling_symlink() {
let dir = tmpdir("dangling_read");
let missing = dir.join("not_there2.txt");
let link = dir.join("linkd2");
symlink(&missing, &link).unwrap();
assert!(read_nofollow(&link).is_err());
assert!(!missing.exists());
}
#[test]
fn test_ops_refuse_regular_file_parent() {
let dir = tmpdir("regfile_parent");
let f = dir.join("notadir");
fs::write(&f, b"x").unwrap();
assert!(write_atomic(f.join("out"), b"y").is_err());
assert!(read_nofollow(f.join("out")).is_err());
assert!(open_append_nofollow(f.join("out")).is_err());
assert!(remove_nofollow(f.join("out")).is_err());
assert!(ensure_state_dir(&f).is_err());
assert_eq!(fs::read_to_string(&f).unwrap(), "x");
}
}