use std::path::{Component, Path, PathBuf};
use tokio::fs;
use crate::error::SshMcpError;
use super::types::{ResolvedPaths, TransferKind, TransferOperation, TransferParams};
pub fn safe_join_local_root(local_root: &Path, user_path: &str) -> Result<PathBuf, String> {
let trimmed = user_path.trim();
if trimmed.is_empty() {
return Err("local_path cannot be empty".to_string());
}
let path = Path::new(trimmed);
if path.has_root() {
let stripped = path.strip_prefix(local_root).map_err(|_| {
format!(
"local_path {} is outside local_root {}",
path.display(),
local_root.display()
)
})?;
for component in stripped.components() {
if matches!(component, Component::ParentDir) {
return Err("local_path must not contain '..'".to_string());
}
}
if stripped.as_os_str().is_empty() {
return Err("local_path must not be the local_root directory itself".to_string());
}
return Ok(path.to_path_buf());
}
let mut saw_normal_component = false;
for component in path.components() {
match component {
Component::Normal(_) => {
saw_normal_component = true;
}
Component::CurDir => {}
Component::ParentDir => {
return Err("local_path must not contain '..'".to_string());
}
Component::RootDir | Component::Prefix(_) => {
return Err("local_path must be a relative path".to_string());
}
}
}
if !saw_normal_component {
return Err("local_path must not normalize to '.'".to_string());
}
Ok(local_root.join(path))
}
pub fn resolve_paths(
local_root: &Path,
params: &TransferParams,
_kind: TransferKind,
) -> Result<ResolvedPaths, String> {
match params.operation {
TransferOperation::Get => {
let local_path = safe_join_local_root(local_root, ¶ms.local_path)?;
Ok(ResolvedPaths { local_path })
}
TransferOperation::Put => {
let local_path = safe_join_local_root(local_root, ¶ms.local_path)?;
Ok(ResolvedPaths { local_path })
}
}
}
pub async fn validate_put_source_no_symlinks(
local_root: &Path,
absolute_source: &Path,
) -> Result<(), String> {
let rel = absolute_source
.strip_prefix(local_root)
.map_err(|_| "local_path must be within local_root".to_string())?;
let mut cursor = local_root.to_path_buf();
for component in rel.components() {
match component {
Component::Normal(seg) => {
cursor.push(seg);
if let Ok(meta) = fs::symlink_metadata(&cursor).await
&& meta.file_type().is_symlink()
{
return Err("local_path traverses a symlink component".to_string());
}
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err("local_path must be a relative path within local_root".to_string());
}
}
}
Ok(())
}
pub async fn validate_get_target_no_symlinks(
local_root: &Path,
absolute_target: &Path,
) -> Result<(), String> {
let rel = absolute_target
.strip_prefix(local_root)
.map_err(|_| "local_path must be within local_root".to_string())?;
let mut cursor = local_root.to_path_buf();
for component in rel.components() {
match component {
Component::Normal(seg) => {
cursor.push(seg);
if let Ok(meta) = fs::symlink_metadata(&cursor).await
&& meta.file_type().is_symlink()
{
return Err("local_path traverses a symlink component".to_string());
}
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err("local_path must be a relative path within local_root".to_string());
}
}
}
Ok(())
}
pub async fn ensure_parent_dirs_no_symlinks(
local_root: &Path,
absolute_target: &Path,
) -> crate::error::Result<()> {
let rel = absolute_target
.strip_prefix(local_root)
.map_err(|_| SshMcpError::invalid_params("local_path must be within local_root"))?;
let mut cursor = local_root.to_path_buf();
let mut comps = rel.components().peekable();
while let Some(component) = comps.next() {
if comps.peek().is_none() {
break;
}
match component {
Component::Normal(seg) => {
cursor.push(seg);
match fs::symlink_metadata(&cursor).await {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(SshMcpError::invalid_params(
"local_path traverses a symlink component",
));
}
if !meta.is_dir() {
return Err(SshMcpError::invalid_params(
"local_path parent component is not a directory",
));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
match fs::create_dir(&cursor).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(SshMcpError::Io(e)),
}
let meta = fs::symlink_metadata(&cursor).await?;
if meta.file_type().is_symlink() {
return Err(SshMcpError::invalid_params(
"local_path traverses a symlink component",
));
}
if !meta.is_dir() {
return Err(SshMcpError::invalid_params(
"local_path parent component is not a directory",
));
}
}
Err(e) => return Err(SshMcpError::Io(e)),
}
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(SshMcpError::invalid_params(
"local_path must be a relative path within local_root",
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_handles_absolute_paths() {
let root = Path::new("/srv");
assert!(safe_join_local_root(root, "/srv/file.txt").is_ok());
assert!(safe_join_local_root(root, "/srv/subdir/file.txt").is_ok());
assert!(safe_join_local_root(root, "/etc/passwd").is_err());
assert!(safe_join_local_root(root, "/other/path").is_err());
assert!(safe_join_local_root(root, "/srv").is_err());
}
#[test]
fn safe_join_rejects_parent_dir() {
let root = Path::new("/srv");
assert!(safe_join_local_root(root, "../x").is_err());
assert!(safe_join_local_root(root, "a/../../x").is_err());
}
#[test]
fn safe_join_allows_normal() {
let root = Path::new("/srv");
let joined = safe_join_local_root(root, "a/b/c.txt").unwrap();
assert_eq!(joined, PathBuf::from("/srv/a/b/c.txt"));
}
#[test]
fn safe_join_rejects_dot() {
let root = Path::new("/srv");
assert!(safe_join_local_root(root, ".").is_err());
assert!(safe_join_local_root(root, "./").is_err());
assert!(safe_join_local_root(root, " ").is_err());
}
}