use crate::error::TemplateReloadError;
#[derive(Debug)]
pub(crate) struct OwnedHandle {
#[cfg(unix)]
inner: std::os::fd::OwnedFd,
#[cfg(windows)]
inner: std::os::windows::io::OwnedHandle,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct FileIdentity {
#[cfg(unix)]
pub(crate) inode: u64,
#[cfg(unix)]
pub(crate) length: u64,
#[cfg(unix)]
pub(crate) mtime_nsec: i64,
#[cfg(windows)]
pub(crate) volume_serial: u32,
#[cfg(windows)]
pub(crate) file_index_high: u32,
#[cfg(windows)]
pub(crate) file_index_low: u32,
#[cfg(windows)]
pub(crate) length: u64,
#[cfg(windows)]
pub(crate) last_write_100ns: i64,
}
#[cfg(unix)]
mod imp {
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use rustix::fs::{Mode, OFlags, StatExt, fstat, openat};
use super::{FileIdentity, OwnedHandle};
use crate::error::TemplateReloadError;
impl OwnedHandle {
#[allow(dead_code)] pub(crate) fn open_relative(
root: &OwnedHandle,
name: &str,
_max_bytes: usize,
) -> Result<(OwnedHandle, FileIdentity), TemplateReloadError> {
let components = validate_components(name)?;
let root_fd = root.inner.as_fd();
let last_idx = components.len() - 1;
let first = openat(
root_fd,
components[0],
if last_idx == 0 {
OFlags::RDONLY | OFlags::NOFOLLOW
} else {
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW
},
Mode::empty(),
)
.map_err(openat_escape_err(components[0]))?;
if components.len() == 1 {
let identity = identity_of(&first)?;
return Ok((OwnedHandle { inner: first }, identity));
}
let mut cur: OwnedFd = first;
for &comp in &components[1..last_idx] {
let next = openat(
cur.as_fd(),
comp,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(openat_escape_err(comp))?;
cur = next;
}
let final_fd = openat(
cur.as_fd(),
components[last_idx],
OFlags::RDONLY | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(openat_escape_err(components[last_idx]))?;
let identity = identity_of(&final_fd)?;
Ok((OwnedHandle { inner: final_fd }, identity))
}
#[allow(dead_code)] pub(crate) fn as_fd(&self) -> BorrowedFd<'_> {
self.inner.as_fd()
}
#[allow(dead_code)] pub(crate) fn read_bounded(
&self,
max_bytes: usize,
) -> Result<Vec<u8>, TemplateReloadError> {
let mut out: Vec<u8> = Vec::new();
let mut buf = [0u8; 8192];
let fd = self.as_fd();
loop {
let n = rustix::io::read(fd, &mut buf)
.map_err(|e| TemplateReloadError::Acquire(format!("read failed: {e}")))?;
if n == 0 {
break;
}
out.extend_from_slice(&buf[..n]);
if out.len() > max_bytes {
return Err(TemplateReloadError::BoundExceeded("max_template_size"));
}
}
Ok(out)
}
}
#[allow(dead_code)] fn identity_of(fd: &OwnedFd) -> Result<FileIdentity, TemplateReloadError> {
let st =
fstat(fd).map_err(|e| TemplateReloadError::Acquire(format!("fstat failed: {e}")))?;
Ok(FileIdentity {
inode: st.st_ino,
length: st.st_size as u64,
mtime_nsec: st.mtime().saturating_mul(1_000_000_000) + st.st_mtime_nsec as i64,
})
}
#[allow(dead_code)] fn openat_escape_err(comp: &str) -> impl Fn(rustix::io::Errno) -> TemplateReloadError + '_ {
move |e| {
if e == rustix::io::Errno::NOENT {
TemplateReloadError::Acquire(format!("template not found: {comp} ({e})"))
} else {
TemplateReloadError::PathEscape(format!(
"openat({comp:?}) confined open failed: {e}"
))
}
}
}
#[allow(dead_code)] fn validate_components(name: &str) -> Result<Vec<&str>, TemplateReloadError> {
let mut out: Vec<&str> = Vec::new();
for comp in name.split('/') {
if comp.is_empty() || comp == "." || comp == ".." {
return Err(TemplateReloadError::PathEscape(format!(
"rejected path component {comp:?} in {name:?}"
)));
}
out.push(comp);
}
if out.is_empty() {
return Err(TemplateReloadError::PathEscape(format!(
"empty template name {name:?}"
)));
}
Ok(out)
}
#[allow(dead_code)] pub(crate) fn open_root_unix(
root_abs_path: &std::path::Path,
) -> Result<(OwnedHandle, FileIdentity), TemplateReloadError> {
use std::path::Component;
for comp in root_abs_path.components() {
if matches!(comp, Component::ParentDir) {
return Err(TemplateReloadError::PathEscape(format!(
"root path contains a parent-dir segment: {}",
root_abs_path.display()
)));
}
}
let fd = match openat(
rustix::fs::CWD,
root_abs_path,
OFlags::RDONLY | OFlags::DIRECTORY,
Mode::empty(),
) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) => {
return Err(TemplateReloadError::PathEscape(
"missing parent".to_string(),
));
}
Err(e) => {
return Err(TemplateReloadError::Acquire(format!(
"open root failed: {e}"
)));
}
};
let identity = identity_of(&fd)?;
Ok((OwnedHandle { inner: fd }, identity))
}
}
#[cfg(windows)]
mod imp {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{
AsRawHandle, FromRawHandle, OwnedHandle as StdOwnedHandle, RawHandle,
};
use std::path::Path;
use windows_sys::Wdk::Foundation::OBJECT_ATTRIBUTES;
use windows_sys::Wdk::Storage::FileSystem::{
FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, NtCreateFile,
};
use windows_sys::Win32::Foundation::{
CloseHandle, HANDLE, INVALID_HANDLE_VALUE, STATUS_SUCCESS, UNICODE_STRING,
};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, GetFileInformationByHandle,
SYNCHRONIZE,
};
use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
use windows_sys::Win32::System::Kernel::OBJ_CASE_INSENSITIVE;
use super::{FileIdentity, OwnedHandle};
use crate::error::TemplateReloadError;
const STATUS_OBJECT_NAME_NOT_FOUND: i32 = 0xC0000034u32 as i32;
impl OwnedHandle {
pub(crate) fn open_relative(
root: &OwnedHandle,
name: &str,
_max_bytes: usize,
) -> Result<(OwnedHandle, FileIdentity), TemplateReloadError> {
let components = validate_components(name)?;
let last_idx = components.len() - 1;
let mut cur_root: HANDLE = root.inner.as_raw_handle() as HANDLE;
let mut held: Option<OwnedHandleInner> = None;
for (i, comp) in components.iter().enumerate() {
let is_last = i == last_idx;
let opened = nt_open_component(cur_root, comp, is_last)?;
if is_last {
let identity = identity_of(opened.0)?;
return Ok((wrap_std(opened)?, identity));
}
held = Some(opened);
cur_root = held.as_ref().expect("just-set").0; }
unreachable!("component list is non-empty (validated)")
}
}
struct OwnedHandleInner(HANDLE);
impl Drop for OwnedHandleInner {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
fn wrap_std(inner: OwnedHandleInner) -> Result<OwnedHandle, TemplateReloadError> {
let raw: RawHandle = std::mem::ManuallyDrop::new(inner).0 as RawHandle;
let std_handle = unsafe { StdOwnedHandle::from_raw_handle(raw) };
Ok(OwnedHandle { inner: std_handle })
}
impl OwnedHandle {
#[allow(dead_code)] pub(crate) fn read_bounded(
&self,
_max_bytes: usize,
) -> Result<Vec<u8>, TemplateReloadError> {
Err(TemplateReloadError::Acquire(
"Windows external template path is unvalidated scaffolding; \
bounded read not yet implemented for NT handles"
.to_string(),
))
}
}
fn nt_open_component(
root: HANDLE,
comp: &str,
_is_last: bool,
) -> Result<OwnedHandleInner, TemplateReloadError> {
let mut name_utf16: Vec<u16> = OsStr::new(comp).encode_wide().collect();
name_utf16.push(0);
let object_name = UNICODE_STRING {
Length: ((name_utf16.len().saturating_sub(1)) * 2) as u16,
MaximumLength: (name_utf16.len() * 2) as u16,
Buffer: name_utf16.as_mut_ptr(),
};
let mut oa: OBJECT_ATTRIBUTES = unsafe { std::mem::zeroed() };
oa.Length = std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32;
oa.RootDirectory = root;
oa.ObjectName = &object_name;
oa.Attributes = OBJ_CASE_INSENSITIVE as u32;
let mut handle: HANDLE = INVALID_HANDLE_VALUE;
let mut io_status: IO_STATUS_BLOCK = unsafe { std::mem::zeroed() };
let status = unsafe {
NtCreateFile(
&mut handle,
SYNCHRONIZE,
&oa,
&mut io_status,
std::ptr::null(),
0,
0,
FILE_OPEN,
FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
std::ptr::null(),
0,
)
};
if status != STATUS_SUCCESS {
return Err(TemplateReloadError::PathEscape(format!(
"NtCreateFile({comp:?}) confined open failed: ntstatus=0x{:08X}",
status as u32
)));
}
if is_reparse(handle)? {
unsafe {
CloseHandle(handle);
}
return Err(TemplateReloadError::PathEscape(format!(
"component {comp:?} is a reparse point (symlink/junction)"
)));
}
Ok(OwnedHandleInner(handle))
}
fn is_reparse(handle: HANDLE) -> Result<bool, TemplateReloadError> {
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle, &mut info) };
if ok == 0 {
return Err(TemplateReloadError::Acquire(
"GetFileInformationByHandle failed".to_string(),
));
}
Ok((info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
}
fn identity_of(handle: HANDLE) -> Result<FileIdentity, TemplateReloadError> {
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle, &mut info) };
if ok == 0 {
return Err(TemplateReloadError::Acquire(
"GetFileInformationByHandle failed".to_string(),
));
}
let length = ((info.nFileSizeHigh as u64) << 32) | (info.nFileSizeLow as u64);
let last_write_100ns: i64 = (((info.ftLastWriteTime.dwHighDateTime as u64) << 32)
| (info.ftLastWriteTime.dwLowDateTime as u64))
as i64;
Ok(FileIdentity {
volume_serial: info.dwVolumeSerialNumber,
file_index_high: info.nFileIndexHigh,
file_index_low: info.nFileIndexLow,
length,
last_write_100ns,
})
}
pub(crate) fn open_root_windows(
root_abs_path: &Path,
) -> Result<(OwnedHandle, FileIdentity), TemplateReloadError> {
use std::path::Component;
for comp in root_abs_path.components() {
if matches!(comp, Component::ParentDir) {
return Err(TemplateReloadError::PathEscape(format!(
"root path contains a parent-dir segment: {}",
root_abs_path.display()
)));
}
}
let mut name_utf16: Vec<u16> = root_abs_path.as_os_str().encode_wide().collect();
let mut prefixed: Vec<u16> = Vec::with_capacity(4 + name_utf16.len());
prefixed.extend([b'\\' as u16, b'?' as u16, b'?' as u16, b'\\' as u16]);
prefixed.extend_from_slice(&name_utf16);
prefixed.push(0);
name_utf16 = prefixed;
let object_name = UNICODE_STRING {
Length: ((name_utf16.len().saturating_sub(1)) * 2) as u16,
MaximumLength: (name_utf16.len() * 2) as u16,
Buffer: name_utf16.as_mut_ptr(),
};
let mut oa: OBJECT_ATTRIBUTES = unsafe { std::mem::zeroed() };
oa.Length = std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32;
oa.RootDirectory = std::ptr::null_mut();
oa.ObjectName = &object_name;
oa.Attributes = OBJ_CASE_INSENSITIVE as u32;
let mut handle: HANDLE = INVALID_HANDLE_VALUE;
let mut io_status: IO_STATUS_BLOCK = unsafe { std::mem::zeroed() };
let status = unsafe {
NtCreateFile(
&mut handle,
SYNCHRONIZE,
&oa,
&mut io_status,
std::ptr::null(),
0,
0,
FILE_OPEN,
FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
std::ptr::null(),
0,
)
};
match status {
STATUS_SUCCESS => {}
STATUS_OBJECT_NAME_NOT_FOUND => {
return Err(TemplateReloadError::PathEscape(
"missing parent".to_string(),
));
}
_ => {
return Err(TemplateReloadError::Acquire(format!(
"open root failed: ntstatus=0x{:08X}",
status as u32
)));
}
}
let inner = OwnedHandleInner(handle);
let identity = identity_of(handle)?;
wrap_std(inner).map(|h| (h, identity))
}
fn validate_components(name: &str) -> Result<Vec<&str>, TemplateReloadError> {
let mut out: Vec<&str> = Vec::new();
for comp in name.split(|c| c == '/' || c == '\\') {
if comp.is_empty() || comp == "." || comp == ".." {
return Err(TemplateReloadError::PathEscape(format!(
"rejected path component {comp:?} in {name:?}"
)));
}
out.push(comp);
}
if out.is_empty() {
return Err(TemplateReloadError::PathEscape(format!(
"empty template name {name:?}"
)));
}
Ok(out)
}
}
#[allow(dead_code)] pub(crate) fn open_root(
root_abs_path: &std::path::Path,
) -> Result<(OwnedHandle, FileIdentity), TemplateReloadError> {
#[cfg(unix)]
{
imp::open_root_unix(root_abs_path)
}
#[cfg(windows)]
{
imp::open_root_windows(root_abs_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[cfg(test)]
mod unix {
use std::fs;
use std::os::unix::fs::symlink;
use super::*;
fn open_root_handle(dir: &std::path::Path) -> OwnedHandle {
let (handle, _id) = open_root(dir).expect("root opens");
handle
}
#[test]
fn owned_handle_open_relative_unix() {
let root = tempfile::tempdir().expect("tempdir");
let child = root.path().join("child");
fs::write(&child, b"hello").expect("write child");
let handle = open_root_handle(root.path());
let (opened, identity) =
OwnedHandle::open_relative(&handle, "child", 1024).expect("open ok");
assert!(identity.length > 0, "identity length must be positive");
let _ = opened;
}
#[test]
fn open_relative_rejects_symlink_escape() {
let outside = tempfile::tempdir().expect("outside tempdir");
let target = outside.path().join("secret");
fs::write(&target, b"escaped").expect("write target");
let root = tempfile::tempdir().expect("root tempdir");
symlink(&target, root.path().join("link")).expect("symlink");
let handle = open_root_handle(root.path());
let err = OwnedHandle::open_relative(&handle, "link", 1024)
.expect_err("symlink leaf must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
#[test]
fn open_relative_rejects_intermediate_symlink_escape() {
let outside = tempfile::tempdir().expect("outside tempdir");
let root = tempfile::tempdir().expect("root tempdir");
let partials = root.path().join("partials");
fs::create_dir(&partials).expect("mkdir partials");
symlink(outside.path(), partials.join("link")).expect("symlink");
fs::write(outside.path().join("page.html"), b"escaped").expect("write target");
let handle = open_root_handle(root.path());
let err = OwnedHandle::open_relative(&handle, "partials/link/page.html", 1024)
.expect_err("intermediate symlink must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
#[test]
fn open_relative_missing_component_maps_to_acquire() {
let root = tempfile::tempdir().expect("root tempdir");
let handle = open_root_handle(root.path());
let err = OwnedHandle::open_relative(&handle, "does_not_exist", 1024)
.expect_err("missing component must error");
assert!(
matches!(err, TemplateReloadError::Acquire(_)),
"expected Acquire for ENOENT, got {err:?}"
);
assert!(
!matches!(err, TemplateReloadError::PathEscape(_)),
"ENOENT must NOT map to PathEscape (regression lock)"
);
}
}
#[test]
fn open_root_rejects_dotdot() {
#[cfg(unix)]
{
let path = std::path::Path::new("/srv/../etc");
let err = open_root(path).expect_err("dotdot root must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
}
}