use mcp_execution_core::sanitize_path_for_error;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OutputDirError {
#[error("server_id must be a single non-empty path segment: {server_id:?}")]
InvalidServerId {
server_id: String,
},
#[error("output_dir must be relative to the servers directory, not absolute: {path}")]
AbsolutePath {
path: String,
},
#[error("output_dir must not contain '..' components: {path}")]
ParentTraversal {
path: String,
},
#[error("server_id directory must not be a symlink: {path}")]
ServerDirIsSymlink {
path: String,
},
#[error("resolved path escapes the servers directory: {path}")]
Escape {
path: String,
},
#[error("path component is not a directory: {path}")]
NotADirectory {
path: String,
},
#[error("failed to create directory {path}: {source}")]
CreateDir {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to resolve servers directory: {0}")]
Io(#[from] std::io::Error),
}
fn validate_server_id_component(server_id: &str) -> Result<Component<'_>, OutputDirError> {
mcp_execution_core::validate_path_segment(server_id).ok_or_else(|| {
OutputDirError::InvalidServerId {
server_id: server_id.to_string(),
}
})
}
pub fn relative_subpath(output_dir: Option<&Path>) -> Result<PathBuf, OutputDirError> {
let Some(path) = output_dir else {
return Ok(PathBuf::new());
};
if path.is_absolute() {
return Err(OutputDirError::AbsolutePath {
path: sanitize_path_for_error(path),
});
}
if mcp_execution_core::contains_parent_dir(path) {
return Err(OutputDirError::ParentTraversal {
path: sanitize_path_for_error(path),
});
}
Ok(path.to_path_buf())
}
async fn resolve_component_strict(current: &Path, root: &Path) -> Result<(), OutputDirError> {
if !current.starts_with(root) {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(current),
});
}
match tokio::fs::symlink_metadata(current).await {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(OutputDirError::ServerDirIsSymlink {
path: sanitize_path_for_error(current),
});
}
if !meta.is_dir() {
return Err(OutputDirError::NotADirectory {
path: sanitize_path_for_error(current),
});
}
Ok(())
}
Err(_) => {
tokio::fs::create_dir(current)
.await
.map_err(|source| OutputDirError::CreateDir {
path: sanitize_path_for_error(current),
source,
})
}
}
}
async fn resolve_component_lenient(
current: &mut PathBuf,
root: &Path,
) -> Result<(), OutputDirError> {
if !current.starts_with(root) {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(current),
});
}
match tokio::fs::symlink_metadata(¤t).await {
Ok(_) => {
let resolved = tokio::fs::canonicalize(¤t).await?;
if !resolved.starts_with(root) {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(current),
});
}
if !tokio::fs::metadata(&resolved).await?.is_dir() {
return Err(OutputDirError::NotADirectory {
path: sanitize_path_for_error(current),
});
}
*current = resolved;
Ok(())
}
Err(_) => {
tokio::fs::create_dir(¤t)
.await
.map_err(|source| OutputDirError::CreateDir {
path: sanitize_path_for_error(current),
source,
})
}
}
}
pub async fn resolve_output_dir(
base_dir: &Path,
server_id: &str,
output_dir: Option<&Path>,
) -> Result<PathBuf, OutputDirError> {
let server_component = validate_server_id_component(server_id)?;
let relative = relative_subpath(output_dir)?;
tokio::fs::create_dir_all(base_dir)
.await
.map_err(|source| OutputDirError::CreateDir {
path: sanitize_path_for_error(base_dir),
source,
})?;
let canonical_root = tokio::fs::canonicalize(base_dir).await?;
let mut server_dir = canonical_root.clone();
server_dir.push(server_component);
resolve_component_strict(&server_dir, &canonical_root).await?;
let sub_components: Vec<Component<'_>> = relative.components().collect();
let Some((last, init)) = sub_components.split_last() else {
return Ok(server_dir);
};
let mut current = server_dir.clone();
for component in init {
current.push(component);
resolve_component_lenient(&mut current, &server_dir).await?;
}
current.push(last);
if !current.starts_with(&server_dir) {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(¤t),
});
}
if let Ok(meta) = tokio::fs::symlink_metadata(¤t).await {
if meta.file_type().is_symlink() {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(¤t),
});
}
let resolved = tokio::fs::canonicalize(¤t).await?;
if !resolved.starts_with(&server_dir) {
return Err(OutputDirError::Escape {
path: sanitize_path_for_error(¤t),
});
}
if !meta.is_dir() {
return Err(OutputDirError::NotADirectory {
path: sanitize_path_for_error(¤t),
});
}
current = resolved;
}
Ok(current)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn default_path_stays_within_base() {
let base = TempDir::new().unwrap();
let resolved = resolve_output_dir(base.path(), "my-server", None)
.await
.unwrap();
let canonical_base = base.path().canonicalize().unwrap();
assert_eq!(resolved, canonical_base.join("my-server"));
}
#[tokio::test]
async fn legitimate_relative_subdir_is_accepted() {
let base = TempDir::new().unwrap();
let resolved =
resolve_output_dir(base.path(), "my-server", Some(Path::new("nested/custom")))
.await
.unwrap();
let canonical_base = base.path().canonicalize().unwrap();
assert_eq!(
resolved,
canonical_base
.join("my-server")
.join("nested")
.join("custom")
);
assert!(!resolved.exists());
assert!(resolved.parent().unwrap().is_dir());
}
#[tokio::test]
async fn regeneration_into_an_existing_directory_is_accepted() {
let base = TempDir::new().unwrap();
let canonical_base = base.path().canonicalize().unwrap();
tokio::fs::create_dir_all(canonical_base.join("my-server"))
.await
.unwrap();
let resolved = resolve_output_dir(base.path(), "my-server", None)
.await
.unwrap();
assert_eq!(resolved, canonical_base.join("my-server"));
}
#[tokio::test]
async fn absolute_output_dir_is_rejected() {
let base = TempDir::new().unwrap();
let absolute = if cfg!(windows) {
r"C:\Windows\System32\config"
} else {
"/etc"
};
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new(absolute)))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::AbsolutePath { .. }));
assert!(!base.path().join("my-server").exists());
}
#[tokio::test]
async fn parent_traversal_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new("../../etc")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::ParentTraversal { .. }));
assert!(!base.path().join("my-server").exists());
}
#[cfg(windows)]
#[tokio::test]
async fn windows_root_relative_path_cannot_escape_base() {
let base = TempDir::new().unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new(r"\pwn\evil")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::Escape { .. }));
}
#[tokio::test]
async fn empty_server_id_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_output_dir(base.path(), "", None).await.unwrap_err();
assert!(matches!(err, OutputDirError::InvalidServerId { .. }));
}
#[tokio::test]
async fn server_id_with_parent_traversal_is_rejected() {
let base = TempDir::new().unwrap();
tokio::fs::create_dir_all(base.path().join("other-server"))
.await
.unwrap();
let err = resolve_output_dir(base.path(), "../other-server", None)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::InvalidServerId { .. }));
}
#[tokio::test]
async fn server_id_with_path_separator_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_output_dir(base.path(), "a/b", None)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::InvalidServerId { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn symlinked_server_id_directory_escape_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), base.path().join("my-server")).unwrap();
let err = resolve_output_dir(base.path(), "my-server", None)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::ServerDirIsSymlink { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn symlinked_server_id_directory_to_sibling_is_rejected() {
let base = TempDir::new().unwrap();
tokio::fs::create_dir_all(base.path().join("server-a"))
.await
.unwrap();
std::os::unix::fs::symlink(base.path().join("server-a"), base.path().join("server-b"))
.unwrap();
let err = resolve_output_dir(base.path(), "server-b", None)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::ServerDirIsSymlink { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn server_id_directory_that_is_a_regular_file_is_rejected() {
let base = TempDir::new().unwrap();
tokio::fs::write(base.path().join("my-server"), "oops")
.await
.unwrap();
let err = resolve_output_dir(base.path(), "my-server", None)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::NotADirectory { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn symlink_loop_is_rejected() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink("a", server_dir.join("a")).unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new("a/custom")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::Io(_)));
}
#[tokio::test]
#[cfg(unix)]
async fn symlinked_intermediate_output_dir_component_escape_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(outside.path(), server_dir.join("escape")).unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new("escape/custom")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::Escape { .. }));
assert!(!outside.path().join("custom").exists());
}
#[tokio::test]
#[cfg(unix)]
async fn dangling_symlink_at_final_component_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let dangling_target = outside.path().join("does-not-exist");
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(&dangling_target, server_dir.join("custom")).unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new("custom")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::Escape { .. }));
assert!(!dangling_target.exists());
}
#[tokio::test]
async fn intermediate_component_that_is_a_regular_file_is_rejected() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
tokio::fs::write(server_dir.join("not-a-dir"), "oops")
.await
.unwrap();
let err = resolve_output_dir(
base.path(),
"my-server",
Some(Path::new("not-a-dir/custom")),
)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::NotADirectory { .. }));
}
#[tokio::test]
async fn final_component_that_is_a_regular_file_is_rejected() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
tokio::fs::write(server_dir.join("custom"), "oops")
.await
.unwrap();
let err = resolve_output_dir(base.path(), "my-server", Some(Path::new("custom")))
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::NotADirectory { .. }));
}
#[tokio::test]
#[cfg(unix)]
async fn rejected_deep_path_leaves_earlier_components_but_nothing_outside_base() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
let kept_dir = server_dir.join("kept");
resolve_output_dir(base.path(), "my-server", Some(Path::new("kept/first")))
.await
.unwrap();
assert!(kept_dir.is_dir());
std::os::unix::fs::symlink(outside.path(), kept_dir.join("escape")).unwrap();
let err = resolve_output_dir(
base.path(),
"my-server",
Some(Path::new("kept/escape/custom")),
)
.await
.unwrap_err();
assert!(matches!(err, OutputDirError::Escape { .. }));
assert!(kept_dir.is_dir());
assert!(!kept_dir.join("escape").join("custom").exists());
assert!(!outside.path().join("custom").exists());
}
}