#[cfg(test)]
use std::os::unix::ffi::OsStringExt;
use std::ffi::{CString, OsStr, OsString};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
use std::path::Path;
use nix::dir::Dir;
use nix::fcntl::{OFlag, openat};
use nix::libc;
use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstatat};
use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, unlinkat};
use crate::translate;
#[derive(thiserror::Error, Debug)]
pub enum SafeTraversalError {
#[error("{}", translate!("safe-traversal-error-path-contains-null"))]
PathContainsNull,
#[error("{}", translate!("safe-traversal-error-open-failed", "path" => path, "source" => source))]
OpenFailed {
path: String,
#[source]
source: io::Error,
},
#[error("{}", translate!("safe-traversal-error-stat-failed", "path" => path, "source" => source))]
StatFailed {
path: String,
#[source]
source: io::Error,
},
#[error("{}", translate!("safe-traversal-error-read-dir-failed", "path" => path, "source" => source))]
ReadDirFailed {
path: String,
#[source]
source: io::Error,
},
#[error("{}", translate!("safe-traversal-error-unlink-failed", "path" => path, "source" => source))]
UnlinkFailed {
path: String,
#[source]
source: io::Error,
},
}
impl From<SafeTraversalError> for io::Error {
fn from(err: SafeTraversalError) -> Self {
match err {
SafeTraversalError::PathContainsNull => Self::new(
io::ErrorKind::InvalidInput,
translate!("safe-traversal-error-path-contains-null"),
),
SafeTraversalError::OpenFailed { source, .. } => source,
SafeTraversalError::StatFailed { source, .. } => source,
SafeTraversalError::ReadDirFailed { source, .. } => source,
SafeTraversalError::UnlinkFailed { source, .. } => source,
}
}
}
fn read_dir_entries(fd: &OwnedFd) -> io::Result<Vec<OsString>> {
let mut entries = Vec::new();
let dup_fd = nix::unistd::dup(fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
let mut dir = Dir::from_fd(dup_fd).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
for entry_result in dir.iter() {
let entry = entry_result.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
let name = entry.file_name();
let name_os = OsStr::from_bytes(name.to_bytes());
if name_os != "." && name_os != ".." {
entries.push(name_os.to_os_string());
}
}
Ok(entries)
}
pub struct DirFd {
fd: OwnedFd,
}
impl DirFd {
pub fn open(path: &Path) -> io::Result<Self> {
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
let fd = nix::fcntl::open(path, flags, Mode::empty()).map_err(|e| {
SafeTraversalError::OpenFailed {
path: path.to_string_lossy().into_owned(),
source: io::Error::from_raw_os_error(e as i32),
}
})?;
Ok(Self { fd })
}
pub fn open_subdir(&self, name: &OsStr) -> io::Result<Self> {
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC;
let fd = openat(&self.fd, name_cstr.as_c_str(), flags, Mode::empty()).map_err(|e| {
SafeTraversalError::OpenFailed {
path: name.to_string_lossy().into_owned(),
source: io::Error::from_raw_os_error(e as i32),
}
})?;
Ok(Self { fd })
}
pub fn stat_at(&self, name: &OsStr, follow_symlinks: bool) -> io::Result<FileStat> {
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
let flags = if follow_symlinks {
nix::fcntl::AtFlags::empty()
} else {
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW
};
let stat = fstatat(&self.fd, name_cstr.as_c_str(), flags).map_err(|e| {
SafeTraversalError::StatFailed {
path: name.to_string_lossy().into_owned(),
source: io::Error::from_raw_os_error(e as i32),
}
})?;
Ok(stat)
}
pub fn metadata_at(&self, name: &OsStr, follow_symlinks: bool) -> io::Result<Metadata> {
self.stat_at(name, follow_symlinks).map(Metadata::from_stat)
}
pub fn metadata(&self) -> io::Result<Metadata> {
self.fstat().map(Metadata::from_stat)
}
pub fn fstat(&self) -> io::Result<FileStat> {
let stat = nix::sys::stat::fstat(&self.fd).map_err(|e| SafeTraversalError::StatFailed {
path: translate!("safe-traversal-current-directory"),
source: io::Error::from_raw_os_error(e as i32),
})?;
Ok(stat)
}
pub fn read_dir(&self) -> io::Result<Vec<OsString>> {
read_dir_entries(&self.fd).map_err(|e| {
SafeTraversalError::ReadDirFailed {
path: translate!("safe-traversal-directory"),
source: e,
}
.into()
})
}
pub fn unlink_at(&self, name: &OsStr, is_dir: bool) -> io::Result<()> {
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
let flags = if is_dir {
UnlinkatFlags::RemoveDir
} else {
UnlinkatFlags::NoRemoveDir
};
unlinkat(&self.fd, name_cstr.as_c_str(), flags).map_err(|e| {
SafeTraversalError::UnlinkFailed {
path: name.to_string_lossy().into_owned(),
source: io::Error::from_raw_os_error(e as i32),
}
})?;
Ok(())
}
pub fn chown_at(
&self,
name: &OsStr,
uid: Option<u32>,
gid: Option<u32>,
follow_symlinks: bool,
) -> io::Result<()> {
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
let flags = if follow_symlinks {
nix::fcntl::AtFlags::empty()
} else {
nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW
};
let uid = uid.map(Uid::from_raw);
let gid = gid.map(Gid::from_raw);
fchownat(&self.fd, name_cstr.as_c_str(), uid, gid, flags)
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
Ok(())
}
pub fn fchown(&self, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
let uid = uid.map(Uid::from_raw);
let gid = gid.map(Gid::from_raw);
fchown(&self.fd, uid, gid).map_err(|e| io::Error::from_raw_os_error(e as i32))?;
Ok(())
}
pub fn chmod_at(&self, name: &OsStr, mode: u32, follow_symlinks: bool) -> io::Result<()> {
let flags = if follow_symlinks {
FchmodatFlags::FollowSymlink
} else {
FchmodatFlags::NoFollowSymlink
};
let mode = Mode::from_bits_truncate(mode);
let name_cstr =
CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?;
fchmodat(&self.fd, name_cstr.as_c_str(), mode, flags)
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
Ok(())
}
pub fn fchmod(&self, mode: u32) -> io::Result<()> {
let mode = Mode::from_bits_truncate(mode);
nix::sys::stat::fchmod(&self.fd, mode)
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
Ok(())
}
pub fn from_raw_fd(fd: RawFd) -> io::Result<Self> {
if fd < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
translate!("safe-traversal-error-invalid-fd"),
));
}
let owned_fd = unsafe { OwnedFd::from_raw_fd(fd) };
Ok(Self { fd: owned_fd })
}
}
impl AsRawFd for DirFd {
fn as_raw_fd(&self) -> RawFd {
self.fd.as_raw_fd()
}
}
impl AsFd for DirFd {
fn as_fd(&self) -> BorrowedFd<'_> {
self.fd.as_fd()
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct FileInfo {
pub dev: u64,
pub ino: u64,
}
impl FileInfo {
pub fn from_stat(stat: &libc::stat) -> Self {
#[allow(clippy::unnecessary_cast)]
Self {
dev: stat.st_dev as u64,
ino: stat.st_ino as u64,
}
}
pub fn new(dev: u64, ino: u64) -> Self {
Self { dev, ino }
}
pub fn device(&self) -> u64 {
self.dev
}
pub fn inode(&self) -> u64 {
self.ino
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
Directory,
RegularFile,
Symlink,
Other,
}
impl FileType {
pub fn from_mode(mode: libc::mode_t) -> Self {
match mode & libc::S_IFMT {
libc::S_IFDIR => Self::Directory,
libc::S_IFREG => Self::RegularFile,
libc::S_IFLNK => Self::Symlink,
_ => Self::Other,
}
}
pub fn is_directory(&self) -> bool {
matches!(self, Self::Directory)
}
pub fn is_regular_file(&self) -> bool {
matches!(self, Self::RegularFile)
}
pub fn is_symlink(&self) -> bool {
matches!(self, Self::Symlink)
}
}
#[derive(Debug, Clone)]
pub struct Metadata {
stat: FileStat,
}
impl Metadata {
pub fn from_stat(stat: FileStat) -> Self {
Self { stat }
}
pub fn file_type(&self) -> FileType {
FileType::from_mode(self.stat.st_mode)
}
pub fn file_info(&self) -> FileInfo {
FileInfo::from_stat(&self.stat)
}
pub fn size(&self) -> u64 {
self.stat.st_size as u64
}
pub fn mode(&self) -> u32 {
self.stat.st_mode
}
pub fn nlink(&self) -> u64 {
#[cfg(target_arch = "x86_64")]
{
self.stat.st_nlink
}
#[cfg(not(target_arch = "x86_64"))]
{
self.stat.st_nlink.into()
}
}
pub fn is_dir(&self) -> bool {
self.file_type().is_directory()
}
pub fn len(&self) -> u64 {
self.size()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl std::os::unix::fs::MetadataExt for Metadata {
fn dev(&self) -> u64 {
self.stat.st_dev
}
fn ino(&self) -> u64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ino.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ino
}
}
fn mode(&self) -> u32 {
self.stat.st_mode
}
fn nlink(&self) -> u64 {
#[cfg(target_arch = "x86_64")]
{
self.stat.st_nlink
}
#[cfg(not(target_arch = "x86_64"))]
{
self.stat.st_nlink.into()
}
}
fn uid(&self) -> u32 {
self.stat.st_uid
}
fn gid(&self) -> u32 {
self.stat.st_gid
}
fn rdev(&self) -> u64 {
self.stat.st_rdev
}
fn size(&self) -> u64 {
self.stat.st_size as u64
}
fn atime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_atime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_atime
}
}
fn atime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_atime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_atime_nsec
}
}
fn mtime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_mtime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_mtime
}
}
fn mtime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_mtime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_mtime_nsec
}
}
fn ctime(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ctime.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ctime
}
}
fn ctime_nsec(&self) -> i64 {
#[cfg(target_pointer_width = "32")]
{
self.stat.st_ctime_nsec.into()
}
#[cfg(not(target_pointer_width = "32"))]
{
self.stat.st_ctime_nsec
}
}
fn blksize(&self) -> u64 {
self.stat.st_blksize as u64
}
fn blocks(&self) -> u64 {
self.stat.st_blocks as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::symlink;
use std::os::unix::io::IntoRawFd;
use tempfile::TempDir;
#[test]
fn test_dirfd_open_valid_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
assert!(dir_fd.as_raw_fd() >= 0);
}
#[test]
fn test_dirfd_open_nonexistent_directory() {
let result = DirFd::open("/nonexistent/path".as_ref());
assert!(result.is_err());
if let Err(e) = result {
assert!(
e.kind() == io::ErrorKind::NotFound || e.kind() == io::ErrorKind::PermissionDenied
);
}
}
#[test]
fn test_dirfd_open_file_not_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file");
fs::write(&file_path, "test content").unwrap();
let result = DirFd::open(&file_path);
assert!(result.is_err());
}
#[test]
fn test_dirfd_open_subdir() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let parent_fd = DirFd::open(temp_dir.path()).unwrap();
let subdir_fd = parent_fd.open_subdir(OsStr::new("subdir")).unwrap();
assert!(subdir_fd.as_raw_fd() >= 0);
}
#[test]
fn test_dirfd_open_nonexistent_subdir() {
let temp_dir = TempDir::new().unwrap();
let parent_fd = DirFd::open(temp_dir.path()).unwrap();
let result = parent_fd.open_subdir(OsStr::new("nonexistent"));
assert!(result.is_err());
}
#[test]
fn test_dirfd_stat_at() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file");
fs::write(&file_path, "test content").unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let stat = dir_fd.stat_at(OsStr::new("test_file"), true).unwrap();
assert!(stat.st_size > 0);
assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFREG);
}
#[test]
fn test_dirfd_stat_at_symlink() {
let temp_dir = TempDir::new().unwrap();
let target_file = temp_dir.path().join("target");
let symlink_file = temp_dir.path().join("link");
fs::write(&target_file, "target content").unwrap();
symlink(&target_file, &symlink_file).unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let stat_follow = dir_fd.stat_at(OsStr::new("link"), true).unwrap();
assert_eq!(stat_follow.st_mode & libc::S_IFMT, libc::S_IFREG);
let stat_nofollow = dir_fd.stat_at(OsStr::new("link"), false).unwrap();
assert_eq!(stat_nofollow.st_mode & libc::S_IFMT, libc::S_IFLNK);
}
#[test]
fn test_dirfd_fstat() {
let temp_dir = TempDir::new().unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let stat = dir_fd.fstat().unwrap();
assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFDIR);
}
#[test]
fn test_dirfd_read_dir() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("file1");
let file2 = temp_dir.path().join("file2");
fs::write(&file1, "content1").unwrap();
fs::write(&file2, "content2").unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let entries = dir_fd.read_dir().unwrap();
assert_eq!(entries.len(), 2);
assert!(entries.contains(&OsString::from("file1")));
assert!(entries.contains(&OsString::from("file2")));
}
#[test]
fn test_dirfd_unlink_at_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file");
fs::write(&file_path, "test content").unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
dir_fd.unlink_at(OsStr::new("test_file"), false).unwrap();
assert!(!file_path.exists());
}
#[test]
fn test_dirfd_unlink_at_directory() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("empty_dir");
fs::create_dir(&subdir).unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
dir_fd.unlink_at(OsStr::new("empty_dir"), true).unwrap();
assert!(!subdir.exists());
}
#[test]
fn test_from_raw_fd() {
let temp_dir = TempDir::new().unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let dup_fd = nix::unistd::dup(&dir_fd).unwrap();
let from_raw_fd = DirFd::from_raw_fd(dup_fd.into_raw_fd()).unwrap();
let stat1 = dir_fd.fstat().unwrap();
let stat2 = from_raw_fd.fstat().unwrap();
assert_eq!(stat1.st_ino, stat2.st_ino);
assert_eq!(stat1.st_dev, stat2.st_dev);
}
#[test]
fn test_from_raw_fd_invalid() {
let result = DirFd::from_raw_fd(-1);
assert!(result.is_err());
}
#[test]
#[allow(clippy::unnecessary_cast)]
fn test_file_info() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file");
fs::write(&file_path, "test content").unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let stat = dir_fd.stat_at(OsStr::new("test_file"), true).unwrap();
let file_info = FileInfo::from_stat(&stat);
assert_eq!(file_info.device(), stat.st_dev as u64);
assert_eq!(file_info.inode(), stat.st_ino as u64);
}
#[test]
fn test_file_info_new() {
let file_info = FileInfo::new(123, 456);
assert_eq!(file_info.device(), 123);
assert_eq!(file_info.inode(), 456);
}
#[test]
fn test_file_type() {
let dir_mode = libc::S_IFDIR | 0o755;
let file_type = FileType::from_mode(dir_mode);
assert_eq!(file_type, FileType::Directory);
assert!(file_type.is_directory());
assert!(!file_type.is_regular_file());
assert!(!file_type.is_symlink());
let file_mode = libc::S_IFREG | 0o644;
let file_type = FileType::from_mode(file_mode);
assert_eq!(file_type, FileType::RegularFile);
assert!(!file_type.is_directory());
assert!(file_type.is_regular_file());
assert!(!file_type.is_symlink());
let link_mode = libc::S_IFLNK | 0o777;
let file_type = FileType::from_mode(link_mode);
assert_eq!(file_type, FileType::Symlink);
assert!(!file_type.is_directory());
assert!(!file_type.is_regular_file());
assert!(file_type.is_symlink());
}
#[test]
#[allow(clippy::unnecessary_cast)]
fn test_metadata_wrapper() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file");
fs::write(&file_path, "test content with some length").unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let metadata = dir_fd.metadata_at(OsStr::new("test_file"), true).unwrap();
assert_eq!(metadata.file_type(), FileType::RegularFile);
assert!(metadata.size() > 0);
assert_eq!(metadata.mode() & libc::S_IFMT as u32, libc::S_IFREG as u32);
assert_eq!(metadata.nlink(), 1);
assert!(metadata.size() > 0);
}
#[test]
fn test_metadata_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let metadata = dir_fd.metadata().unwrap();
assert_eq!(metadata.file_type(), FileType::Directory);
assert!(metadata.file_type().is_directory());
}
#[test]
fn test_path_with_null_byte() {
let path_with_null = std::ffi::OsString::from_vec(b"test\0file".to_vec());
let temp_dir = TempDir::new().unwrap();
let dir_fd = DirFd::open(temp_dir.path()).unwrap();
let result = dir_fd.open_subdir(&path_with_null);
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
}
}
#[test]
fn test_error_chain() {
let result = DirFd::open("/nonexistent/deeply/nested/path".as_ref());
assert!(result.is_err());
if let Err(e) = result {
let io_err: io::Error = e;
assert!(
io_err.kind() == io::ErrorKind::NotFound
|| io_err.kind() == io::ErrorKind::PermissionDenied
);
}
}
}