use std::borrow::Cow;
use std::fs::{File, Metadata};
use std::path::{Path, PathBuf};
use crate::error::StaticError;
pub(crate) struct ResolvedFile {
pub(crate) file: File,
pub(crate) metadata: Metadata,
pub(crate) path: PathBuf,
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
let mut buf = [0u8; libc::PATH_MAX as usize];
let rc = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr()) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
}
#[cfg(target_os = "linux")]
fn real_path_of(file: &File) -> std::io::Result<PathBuf> {
use std::os::fd::AsRawFd;
std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))
}
#[cfg_attr(
any(target_os = "macos", target_os = "ios", target_os = "linux"),
allow(dead_code)
)]
pub(crate) fn canonicalize_within_root(
root_canon: &Path,
joined: &Path,
) -> Result<PathBuf, StaticError> {
let canon = joined
.canonicalize()
.map_err(|_| StaticError::NotFound(joined.display().to_string()))?;
if canon.starts_with(root_canon) {
Ok(canon)
} else {
Err(StaticError::Traversal(joined.display().to_string()))
}
}
pub fn resolve_with_canonical_root(
root_canon: &Path,
request_path: &str,
) -> Result<PathBuf, StaticError> {
resolve_with_policy(root_canon, request_path, HiddenFiles::Deny)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HiddenFiles {
Deny,
Serve,
}
const WELL_KNOWN: &str = ".well-known";
pub(crate) const INDEX_FILE_NAME: &str = "index.html";
fn has_hidden_segment(segments: &[Cow<'_, str>]) -> bool {
segments.iter().enumerate().any(|(index, segment)| {
let is_well_known_root = index == 0 && segment.as_ref() == WELL_KNOWN;
segment.starts_with('.') && segment != "." && !is_well_known_root
})
}
pub(crate) fn decode_segments(request_path: &str) -> Result<Vec<Cow<'_, str>>, StaticError> {
let mut segments = Vec::new();
for raw in request_path.split('/') {
if raw.is_empty() {
continue;
}
let decoded = percent_encoding::percent_decode_str(raw)
.decode_utf8()
.unwrap_or(Cow::Borrowed(raw));
check_segment(&decoded, request_path)?;
segments.push(decoded);
}
Ok(segments)
}
pub(crate) fn check_segment(decoded: &str, request_path: &str) -> Result<(), StaticError> {
let refuse = || StaticError::Traversal(request_path.to_string());
if decoded.contains('/') || decoded.contains('\\') || decoded.contains('\0') {
return Err(refuse());
}
if decoded == ".." {
return Err(refuse());
}
Ok(())
}
fn join_segments(root_canon: &Path, segments: &[Cow<'_, str>]) -> PathBuf {
let mut path = root_canon.to_path_buf();
for segment in segments {
path.push(segment.as_ref());
}
path
}
pub(crate) fn resolve_with_policy(
root_canon: &Path,
request_path: &str,
hidden: HiddenFiles,
) -> Result<PathBuf, StaticError> {
open_with_policy(root_canon, request_path, hidden).map(|resolved| resolved.path)
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
fn open_verified(
root_canon: &Path,
joined: &Path,
request_path: &str,
) -> Result<ResolvedFile, StaticError> {
let file = File::open(joined).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
let real = real_path_of(&file).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
if !real.starts_with(root_canon) {
return Err(StaticError::Traversal(request_path.to_string()));
}
let metadata = file
.metadata()
.map_err(|_| StaticError::NotFound(request_path.to_string()))?;
if metadata.is_dir() {
return open_verified(root_canon, &real.join(INDEX_FILE_NAME), request_path);
}
Ok(ResolvedFile {
file,
metadata,
path: real,
})
}
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
fn open_verified(
root_canon: &Path,
joined: &Path,
request_path: &str,
) -> Result<ResolvedFile, StaticError> {
let canon = canonicalize_within_root(root_canon, joined)?;
let target = if canon.is_dir() {
let index = canon.join(INDEX_FILE_NAME);
canonicalize_within_root(root_canon, &index)?;
index
} else {
canon
};
let file = File::open(&target).map_err(|_| StaticError::NotFound(request_path.to_string()))?;
let metadata = file
.metadata()
.map_err(|_| StaticError::NotFound(request_path.to_string()))?;
Ok(ResolvedFile {
file,
metadata,
path: target,
})
}
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "linux"))]
pub(crate) fn open_sidecar_verified(root_canon: &Path, sidecar: &Path) -> Option<ResolvedFile> {
let file = File::open(sidecar).ok()?;
let real = real_path_of(&file).ok()?;
if !real.starts_with(root_canon) {
return None;
}
let metadata = file.metadata().ok()?;
if !metadata.is_file() {
return None;
}
Some(ResolvedFile {
file,
metadata,
path: real,
})
}
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "linux")))]
pub(crate) fn open_sidecar_verified(root_canon: &Path, sidecar: &Path) -> Option<ResolvedFile> {
let canon = canonicalize_within_root(root_canon, sidecar).ok()?;
let file = File::open(&canon).ok()?;
let metadata = file.metadata().ok()?;
if !metadata.is_file() {
return None;
}
Some(ResolvedFile {
file,
metadata,
path: canon,
})
}
pub(crate) fn open_segments<S: AsRef<str>>(
root_canon: &Path,
segments: &[S],
request_path: &str,
hidden: HiddenFiles,
) -> Result<ResolvedFile, StaticError> {
let checked = servable_segments(segments, request_path, hidden)?;
open_checked(root_canon, &checked, request_path, hidden)
}
pub(crate) fn servable_segments<'a, S: AsRef<str>>(
segments: &'a [S],
request_path: &str,
hidden: HiddenFiles,
) -> Result<Vec<Cow<'a, str>>, StaticError> {
let checked: Vec<Cow<'a, str>> = segments
.iter()
.map(|segment| {
check_segment(segment.as_ref(), request_path)?;
Ok(Cow::Borrowed(segment.as_ref()))
})
.collect::<Result<_, StaticError>>()?;
if hidden == HiddenFiles::Deny && has_hidden_segment(&checked) {
return Err(StaticError::NotFound(request_path.to_string()));
}
Ok(checked)
}
pub(crate) fn open_with_policy(
root_canon: &Path,
request_path: &str,
hidden: HiddenFiles,
) -> Result<ResolvedFile, StaticError> {
let segments = decode_segments(request_path)?;
open_checked(root_canon, &segments, request_path, hidden)
}
fn open_checked(
root_canon: &Path,
segments: &[Cow<'_, str>],
request_path: &str,
hidden: HiddenFiles,
) -> Result<ResolvedFile, StaticError> {
if hidden == HiddenFiles::Deny && has_hidden_segment(segments) {
return Err(StaticError::NotFound(request_path.to_string()));
}
open_verified(root_canon, &join_segments(root_canon, segments), request_path)
}
pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
let root_canon = root.canonicalize().map_err(StaticError::Io)?;
resolve_with_canonical_root(&root_canon, request_path)
}