use super::{CompiledService, SymlinkPolicy}; use std::{fs::File, io, path::Path}; #[cfg(unix)] use std::ffi::CString; #[derive(Debug)] pub(super) struct Opened { pub(super) file: File, pub(super) metadata: std::fs::Metadata, } #[derive(Debug, Clone)] pub(super) struct DirectoryEntry { pub(super) name: String, pub(super) directory: bool, } #[cfg(unix)] fn cstring_component(value: &str) -> io::Result { CString::new(value.as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL")) } #[cfg(unix)] pub(super) fn open_root_directory(path: &Path) -> io::Result { use std::os::unix::{ffi::OsStrExt, io::FromRawFd}; let path = CString::new(path.as_os_str().as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; let fd = unsafe { libc::open( path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, ) }; if fd < 0 { Err(io::Error::last_os_error()) } else { Ok(unsafe { File::from_raw_fd(fd) }) } } pub(super) fn path_components(path: &str) -> io::Result> { if !path.starts_with('/') { return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute")); } let mut result = Vec::new(); for part in path.split('/').filter(|part| !part.is_empty()) { if part == "." || part == ".." || part.contains('\\') || part.contains('\0') { return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path")); } result.push(part.to_owned()); } Ok(result) } #[cfg(unix)] pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { use std::os::unix::io::{AsRawFd, FromRawFd}; if components.is_empty() { // `File::try_clone()` uses dup(2), which shares the directory offset with // the persistent root descriptor. Passing such a clone to readdir(3) // would leave the next root listing positioned at EOF. openat(".") // creates a fresh open file description and therefore a fresh offset for // every request while retaining the same O_NOFOLLOW confinement. let dot = cstring_component(".")?; let fd = unsafe { libc::openat( service.root_handle.as_raw_fd(), dot.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, ) }; if fd < 0 { return Err(io::Error::last_os_error()); } let current = unsafe { File::from_raw_fd(fd) }; let metadata = current.metadata()?; return Ok(Opened { file: current, metadata }); } let mut current = service.root_handle.try_clone()?; for (index, component) in components.iter().enumerate() { let name = cstring_component(component)?; let intermediate = index + 1 != components.len(); // The default remains O_NOFOLLOW on every component. `inside_root` is an // explicit Linux-only opt-in: the opened descriptor is checked through // /proc/self/fd after each resolution step before it can be used further. let mut flags = libc::O_RDONLY | libc::O_CLOEXEC; if service.symlink_policy == SymlinkPolicy::Deny { flags |= libc::O_NOFOLLOW; } if intermediate { flags |= libc::O_DIRECTORY; } else { // Avoid blocking on special files. Only regular files/directories are served. flags |= libc::O_NONBLOCK; } let fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) }; if fd < 0 { return Err(io::Error::last_os_error()); } if service.symlink_policy == SymlinkPolicy::InsideRoot { #[cfg(target_os = "linux")] verify_linux_fd_beneath_root(service, fd)?; #[cfg(not(target_os = "linux"))] { unsafe { libc::close(fd) }; return Err(io::Error::new( io::ErrorKind::Unsupported, "inside_root symlink policy requires Linux", )); } } current = unsafe { File::from_raw_fd(fd) }; } let metadata = current.metadata()?; if !metadata.is_file() && !metadata.is_dir() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "Web Server serves only regular files and directories", )); } Ok(Opened { file: current, metadata }) } #[cfg(target_os = "linux")] fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> io::Result<()> { let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) { Ok(target) => target, Err(error) => { unsafe { libc::close(fd) }; return Err(error); } }; if target.starts_with(&service.root) { Ok(()) } else { unsafe { libc::close(fd) }; Err(io::Error::new( io::ErrorKind::PermissionDenied, "symlink target escaped document root", )) } } #[cfg(not(unix))] pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { let mut candidate = service.root.clone(); for component in components { candidate.push(component); } let canonical = std::fs::canonicalize(&candidate)?; if !canonical.starts_with(&service.root) { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "path escaped document root", )); } let file = File::open(&canonical)?; let metadata = file.metadata()?; if !metadata.is_file() && !metadata.is_dir() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, "Web Server serves only regular files and directories", )); } Ok(Opened { file, metadata }) } #[cfg(unix)] pub(super) fn list_directory_sync( service: &CompiledService, components: &[String], ) -> io::Result> { use std::ffi::CStr; use std::os::unix::io::IntoRawFd; let opened = secure_open_sync(service, components)?; if !opened.metadata.is_dir() { return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); } let raw = opened.file.try_clone()?.into_raw_fd(); let dir = unsafe { libc::fdopendir(raw) }; if dir.is_null() { let error = io::Error::last_os_error(); unsafe { libc::close(raw) }; return Err(error); } let mut entries = Vec::new(); loop { let entry = unsafe { libc::readdir(dir) }; if entry.is_null() { break; } let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); if name == b"." || name == b".." { continue; } let Ok(name) = std::str::from_utf8(name) else { // Routing intentionally accepts only valid UTF-8, so do not emit an // unusable or ambiguously decoded link for a non-UTF-8 file name. continue; }; let mut child = components.to_vec(); child.push(name.to_owned()); // Re-open through the root fd using the configured symlink policy. The // default omits every symlink; Linux inside_root may include a target only // after the opened descriptor has been proven to remain below the root. let Ok(child_opened) = secure_open_sync(service, &child) else { continue; }; entries.push(DirectoryEntry { name: name.to_owned(), directory: child_opened.metadata.is_dir(), }); } unsafe { libc::closedir(dir) }; entries.sort_by(|a, b| { b.directory .cmp(&a.directory) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) .then_with(|| a.name.cmp(&b.name)) }); Ok(entries) } #[cfg(not(unix))] pub(super) fn list_directory_sync( service: &CompiledService, components: &[String], ) -> io::Result> { let opened = secure_open_sync(service, components)?; if !opened.metadata.is_dir() { return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); } let mut path = service.root.clone(); for component in components { path.push(component); } let canonical = std::fs::canonicalize(path)?; if !canonical.starts_with(&service.root) { return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root")); } let mut entries = Vec::new(); for entry in std::fs::read_dir(canonical)? { let entry = entry?; let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue; }; let mut child = components.to_vec(); child.push(name.clone()); let Ok(child_opened) = secure_open_sync(service, &child) else { continue; }; entries.push(DirectoryEntry { name, directory: child_opened.metadata.is_dir(), }); } entries.sort_by(|a, b| { b.directory .cmp(&a.directory) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) .then_with(|| a.name.cmp(&b.name)) }); Ok(entries) }