use std::{
fs,
io::ErrorKind,
path::{Component, Path, PathBuf},
};
use super::error::MountError;
const PATH_MAX: usize = 4096;
const NAME_MAX: usize = 255;
#[derive(Debug)]
pub(super) struct ResolvedPath {
pub host_path: PathBuf,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum ResolveMode {
Existing,
Lstat,
Creation,
MkdirParents,
}
pub(super) fn resolve_path(
virtual_path: &str,
mount_virtual_path: &str,
mount_host_path: &Path,
mode: ResolveMode,
) -> Result<ResolvedPath, MountError> {
let request = ResolutionRequest::new(virtual_path, mount_virtual_path, mount_host_path)?;
let host_path = match mode {
ResolveMode::Existing => resolve_existing(&request, mount_host_path)?,
ResolveMode::Lstat => resolve_lstat(&request, mount_host_path)?,
ResolveMode::Creation => resolve_creation(&request, mount_host_path)?,
ResolveMode::MkdirParents => resolve_mkdir_parents(&request, mount_host_path)?,
};
Ok(ResolvedPath { host_path })
}
#[must_use]
pub(super) fn normalize_virtual_path(path: &str) -> String {
if is_already_normalized_absolute_path(path) {
return path.to_owned();
}
let mut components = Vec::new();
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
components.pop();
}
_ => components.push(part),
}
}
if components.is_empty() {
"/".to_owned()
} else {
format!("/{}", components.join("/"))
}
}
#[must_use]
pub(super) fn strip_mount_prefix<'a>(normalized_path: &'a str, mount_virtual_path: &str) -> Option<&'a str> {
if mount_virtual_path == "/" {
return Some(normalized_path.strip_prefix('/').unwrap_or(normalized_path));
}
if normalized_path == mount_virtual_path {
return Some("");
}
normalized_path
.strip_prefix(mount_virtual_path)
.and_then(|rest| rest.strip_prefix('/'))
}
struct ResolutionRequest {
normalized_virtual: String,
relative: String,
candidate_host: PathBuf,
}
impl ResolutionRequest {
fn new(virtual_path: &str, mount_virtual_path: &str, mount_host_path: &Path) -> Result<Self, MountError> {
reject_null_bytes(virtual_path)?;
let normalized_virtual = normalize_virtual_path(virtual_path);
reject_overlong_path(&normalized_virtual, virtual_path)?;
let relative = strip_mount_prefix(&normalized_virtual, mount_virtual_path)
.ok_or_else(|| MountError::NoMountPoint(virtual_path.to_owned()))?
.to_owned();
let candidate_host = if relative.is_empty() {
mount_host_path.to_path_buf()
} else {
mount_host_path.join(&relative)
};
reject_parent_components(&candidate_host, &normalized_virtual)?;
Ok(Self {
normalized_virtual,
relative,
candidate_host,
})
}
fn final_component(&self) -> Result<&str, MountError> {
let file_name = self
.candidate_host
.file_name()
.ok_or_else(|| MountError::PathEscape {
virtual_path: self.normalized_virtual.clone(),
})?
.to_str()
.ok_or_else(|| MountError::PathEscape {
virtual_path: self.normalized_virtual.clone(),
})?;
if file_name.contains('/') || file_name.contains('\\') || matches!(file_name, "." | "..") {
return Err(MountError::PathEscape {
virtual_path: self.normalized_virtual.clone(),
});
}
Ok(file_name)
}
}
fn resolve_existing(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
let canonical = fs::canonicalize(&request.candidate_host)
.map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical, mount_host_path, &request.normalized_virtual)?;
Ok(canonical)
}
fn resolve_lstat(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
if request.relative.is_empty() {
let canonical =
fs::canonicalize(mount_host_path).map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical, mount_host_path, &request.normalized_virtual)?;
return Ok(canonical);
}
let parent = request.candidate_host.parent().ok_or_else(|| MountError::PathEscape {
virtual_path: request.normalized_virtual.clone(),
})?;
let file_name = request
.candidate_host
.file_name()
.ok_or_else(|| MountError::PathEscape {
virtual_path: request.normalized_virtual.clone(),
})?;
let canonical_parent =
fs::canonicalize(parent).map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical_parent, mount_host_path, &request.normalized_virtual)?;
Ok(canonical_parent.join(file_name))
}
fn resolve_creation(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
if request.candidate_host.exists() {
return resolve_existing(request, mount_host_path);
}
let parent = request.candidate_host.parent().ok_or_else(|| MountError::PathEscape {
virtual_path: request.normalized_virtual.clone(),
})?;
let file_name = request.final_component()?;
let canonical_parent =
fs::canonicalize(parent).map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical_parent, mount_host_path, &request.normalized_virtual)?;
let resolved_path = canonical_parent.join(file_name);
validate_creation_symlink_target(
&resolved_path,
&canonical_parent,
mount_host_path,
&request.normalized_virtual,
)?;
Ok(resolved_path)
}
fn resolve_mkdir_parents(request: &ResolutionRequest, mount_host_path: &Path) -> Result<PathBuf, MountError> {
if request.relative.is_empty() {
let canonical =
fs::canonicalize(mount_host_path).map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical, mount_host_path, &request.normalized_virtual)?;
return Ok(canonical);
}
let components: Vec<&str> = request
.relative
.split('/')
.filter(|component| !component.is_empty())
.collect();
let mut current = mount_host_path.to_path_buf();
for (index, component) in components.iter().enumerate() {
if matches!(*component, "." | "..") {
return Err(MountError::PathEscape {
virtual_path: request.normalized_virtual.clone(),
});
}
let next = current.join(component);
if next.exists() {
let canonical =
fs::canonicalize(&next).map_err(|err| MountError::Io(err, request.normalized_virtual.clone()))?;
check_boundary(&canonical, mount_host_path, &request.normalized_virtual)?;
current = canonical;
} else {
for remaining in &components[index..] {
current = current.join(remaining);
}
return Ok(current);
}
}
Ok(current)
}
fn reject_null_bytes(virtual_path: &str) -> Result<(), MountError> {
if virtual_path.contains('\0') {
return Err(MountError::PathEscape {
virtual_path: virtual_path.to_owned(),
});
}
Ok(())
}
pub(super) fn reject_overlong_path(normalized: &str, original: &str) -> Result<(), MountError> {
if normalized.len() > PATH_MAX {
return Err(MountError::io_err(
ErrorKind::InvalidFilename,
"File name too long",
original,
));
}
for component in normalized.split('/') {
if component.len() > NAME_MAX {
return Err(MountError::io_err(
ErrorKind::InvalidFilename,
"File name too long",
original,
));
}
}
Ok(())
}
fn reject_parent_components(candidate_host_path: &Path, normalized_virtual_path: &str) -> Result<(), MountError> {
for component in candidate_host_path.components() {
if matches!(component, Component::ParentDir) {
return Err(MountError::PathEscape {
virtual_path: normalized_virtual_path.to_owned(),
});
}
}
Ok(())
}
fn validate_creation_symlink_target(
resolved_path: &Path,
canonical_parent: &Path,
mount_host_path: &Path,
normalized_virtual_path: &str,
) -> Result<(), MountError> {
if !resolved_path
.symlink_metadata()
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return Ok(());
}
let link_target =
fs::read_link(resolved_path).map_err(|err| MountError::Io(err, normalized_virtual_path.to_owned()))?;
let resolved_target = if link_target.is_absolute() {
link_target
} else {
canonical_parent.join(&link_target)
};
let canonical_target = if let Ok(canonical) = fs::canonicalize(&resolved_target) {
canonical
} else if let Some(parent) = resolved_target.parent() {
match fs::canonicalize(parent) {
Ok(canonical_parent) => canonical_parent.join(resolved_target.file_name().unwrap_or_default()),
Err(_) => {
return Err(MountError::PathEscape {
virtual_path: normalized_virtual_path.to_owned(),
});
}
}
} else {
return Err(MountError::PathEscape {
virtual_path: normalized_virtual_path.to_owned(),
});
};
check_boundary(&canonical_target, mount_host_path, normalized_virtual_path)
}
fn is_already_normalized_absolute_path(path: &str) -> bool {
if path == "/" {
return true;
}
if !path.starts_with('/') || path.ends_with('/') {
return false;
}
for part in path[1..].split('/') {
if part.is_empty() || matches!(part, "." | "..") {
return false;
}
}
true
}
pub(super) fn reject_escaping_symlink(
host_path: &Path,
mount_host_path: &Path,
virtual_path: &str,
) -> Result<(), MountError> {
let target = fs::read_link(host_path).map_err(|e| MountError::Io(e, virtual_path.to_owned()))?;
let resolved = if target.is_relative() {
let parent = host_path.parent().ok_or_else(|| MountError::PathEscape {
virtual_path: virtual_path.to_owned(),
})?;
parent.join(&target)
} else {
target
};
let canonical = fs::canonicalize(&resolved).map_err(|_| MountError::PathEscape {
virtual_path: virtual_path.to_owned(),
})?;
let canonical_mount = fs::canonicalize(mount_host_path).map_err(|e| MountError::Io(e, virtual_path.to_owned()))?;
check_boundary(&canonical, &canonical_mount, virtual_path)
}
fn check_boundary(canonical_path: &Path, mount_host_path: &Path, virtual_path: &str) -> Result<(), MountError> {
if canonical_path.starts_with(mount_host_path) {
Ok(())
} else {
Err(MountError::PathEscape {
virtual_path: virtual_path.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_virtual_path() {
assert_eq!(normalize_virtual_path("/data/file.txt"), "/data/file.txt");
assert_eq!(normalize_virtual_path("/data/./file.txt"), "/data/file.txt");
assert_eq!(normalize_virtual_path("/data/../etc/passwd"), "/etc/passwd");
assert_eq!(normalize_virtual_path("/../../../etc/passwd"), "/etc/passwd");
assert_eq!(normalize_virtual_path("/"), "/");
assert_eq!(normalize_virtual_path("/data/"), "/data");
assert_eq!(normalize_virtual_path("/a/b/../c/./d"), "/a/c/d");
}
#[test]
fn test_strip_mount_prefix() {
assert_eq!(strip_mount_prefix("/data/file.txt", "/data"), Some("file.txt"));
assert_eq!(strip_mount_prefix("/data", "/data"), Some(""));
assert_eq!(strip_mount_prefix("/data/sub/file", "/data"), Some("sub/file"));
assert_eq!(strip_mount_prefix("/other/file", "/data"), None);
assert_eq!(strip_mount_prefix("/anything", "/"), Some("anything"));
assert_eq!(strip_mount_prefix("/", "/"), Some(""));
assert_eq!(strip_mount_prefix("/data2/file", "/data"), None);
}
}