use mcp_execution_core::sanitize_path_for_error;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OutputPathError {
#[error("server_id must be a single non-empty path segment: {server_id:?}")]
InvalidServerId {
server_id: String,
},
#[error("output_path must be relative to the skills directory, not absolute: {path}")]
AbsolutePath {
path: String,
},
#[error("output_path must not contain '..' components: {path}")]
ParentTraversal {
path: String,
},
#[error("output_path is not a valid file path: {path}")]
InvalidPath {
path: String,
},
#[error("server_id directory must not be a symlink: {path}")]
ServerIdIsSymlink {
path: String,
},
#[error("resolved path escapes the skills directory: {path}")]
Escape {
path: String,
},
#[error("path component is not a directory: {path}")]
NotADirectory {
path: String,
},
#[error("output path is a directory, not a file: {path}")]
NotAFile {
path: String,
},
#[error("failed to create directory {path}: {source}")]
CreateDir {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to resolve skills directory: {0}")]
Io(#[from] std::io::Error),
}
fn validate_server_id_segment(server_id: &str) -> Result<Component<'_>, OutputPathError> {
mcp_execution_core::validate_path_segment(server_id).ok_or_else(|| {
OutputPathError::InvalidServerId {
server_id: server_id.to_string(),
}
})
}
fn relative_target(output_path: Option<&Path>) -> Result<PathBuf, OutputPathError> {
let Some(path) = output_path else {
return Ok(PathBuf::from("SKILL.md"));
};
if path.is_absolute() {
return Err(OutputPathError::AbsolutePath {
path: sanitize_path_for_error(path),
});
}
if mcp_execution_core::contains_parent_dir(path) {
return Err(OutputPathError::ParentTraversal {
path: sanitize_path_for_error(path),
});
}
if path.file_name().is_none() {
return Err(OutputPathError::InvalidPath {
path: sanitize_path_for_error(path),
});
}
Ok(path.to_path_buf())
}
pub async fn resolve_skill_output_path(
base_dir: &Path,
server_id: &str,
output_path: Option<&Path>,
) -> Result<PathBuf, OutputPathError> {
let server_component = validate_server_id_segment(server_id)?;
let relative = relative_target(output_path)?;
tokio::fs::create_dir_all(base_dir)
.await
.map_err(|source| OutputPathError::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);
if !server_dir.starts_with(&canonical_root) {
return Err(OutputPathError::Escape {
path: sanitize_path_for_error(&server_dir),
});
}
match tokio::fs::symlink_metadata(&server_dir).await {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(OutputPathError::ServerIdIsSymlink {
path: sanitize_path_for_error(&server_dir),
});
}
if !meta.is_dir() {
return Err(OutputPathError::NotADirectory {
path: sanitize_path_for_error(&server_dir),
});
}
}
Err(_) => {
tokio::fs::create_dir(&server_dir).await.map_err(|source| {
OutputPathError::CreateDir {
path: sanitize_path_for_error(&server_dir),
source,
}
})?;
}
}
let output_dir_components = relative
.parent()
.map(|parent| parent.components().collect::<Vec<_>>())
.unwrap_or_default();
let mut current = server_dir.clone();
for component in output_dir_components {
current.push(component);
if !current.starts_with(&server_dir) {
return Err(OutputPathError::Escape {
path: sanitize_path_for_error(¤t),
});
}
match tokio::fs::symlink_metadata(¤t).await {
Ok(_) => {
let resolved = tokio::fs::canonicalize(¤t).await?;
if !resolved.starts_with(&server_dir) {
return Err(OutputPathError::Escape {
path: sanitize_path_for_error(¤t),
});
}
if !tokio::fs::metadata(&resolved).await?.is_dir() {
return Err(OutputPathError::NotADirectory {
path: sanitize_path_for_error(¤t),
});
}
current = resolved;
}
Err(_) => {
tokio::fs::create_dir(¤t).await.map_err(|source| {
OutputPathError::CreateDir {
path: sanitize_path_for_error(¤t),
source,
}
})?;
}
}
}
let Some(file_name) = relative.file_name() else {
return Err(OutputPathError::InvalidPath {
path: sanitize_path_for_error(&relative),
});
};
let final_path = current.join(file_name);
if let Ok(meta) = tokio::fs::symlink_metadata(&final_path).await {
if meta.file_type().is_symlink() {
return Err(OutputPathError::Escape {
path: sanitize_path_for_error(&final_path),
});
}
if meta.is_dir() {
return Err(OutputPathError::NotAFile {
path: sanitize_path_for_error(&final_path),
});
}
}
Ok(final_path)
}
#[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_skill_output_path(base.path(), "my-server", None)
.await
.unwrap();
let canonical_base = base.path().canonicalize().unwrap();
assert!(resolved.starts_with(&canonical_base));
assert_eq!(resolved, canonical_base.join("my-server").join("SKILL.md"));
}
#[tokio::test]
async fn legitimate_relative_path_is_accepted() {
let base = TempDir::new().unwrap();
let resolved =
resolve_skill_output_path(base.path(), "my-server", Some(Path::new("custom/SKILL.md")))
.await
.unwrap();
let canonical_base = base.path().canonicalize().unwrap();
assert!(resolved.starts_with(&canonical_base));
assert_eq!(
resolved,
canonical_base
.join("my-server")
.join("custom")
.join("SKILL.md")
);
}
#[tokio::test]
async fn empty_server_id_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_skill_output_path(base.path(), "", None)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
assert!(
tokio::fs::read_dir(base.path())
.await
.unwrap()
.next_entry()
.await
.unwrap()
.is_none()
);
}
#[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_skill_output_path(base.path(), "../other-server", None)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
}
#[tokio::test]
async fn server_id_with_path_separator_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_skill_output_path(base.path(), "a/b", None)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::InvalidServerId { .. }));
}
#[tokio::test]
async fn absolute_path_is_rejected() {
let base = TempDir::new().unwrap();
let absolute = if cfg!(windows) {
r"C:\Windows\System32\config"
} else {
"/etc/passwd"
};
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new(absolute)))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::AbsolutePath { .. }));
assert!(!base.path().join("my-server").exists());
}
#[tokio::test]
async fn parent_traversal_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_skill_output_path(
base.path(),
"my-server",
Some(Path::new("../../etc/passwd")),
)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::ParentTraversal { .. }));
assert!(!base.path().join("my-server").exists());
}
#[tokio::test]
async fn path_with_no_file_name_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new(".")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::InvalidPath { .. }));
}
#[tokio::test]
async fn empty_output_path_is_rejected() {
let base = TempDir::new().unwrap();
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::InvalidPath { .. }));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_root_relative_path_cannot_escape_base() {
let base = TempDir::new().unwrap();
let err =
resolve_skill_output_path(base.path(), "my-server", Some(Path::new(r"\pwn\evil.md")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::Escape { .. }));
}
#[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_skill_output_path(base.path(), "my-server", None)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::ServerIdIsSymlink { .. }));
assert!(!outside.path().join("SKILL.md").exists());
}
#[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_skill_output_path(base.path(), "server-b", None)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::ServerIdIsSymlink { .. }));
assert!(!base.path().join("server-a").join("SKILL.md").exists());
}
#[tokio::test]
#[cfg(unix)]
async fn symlinked_parent_directory_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_skill_output_path(base.path(), "my-server", Some(Path::new("escape/SKILL.md")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::Escape { .. }));
assert!(!outside.path().join("SKILL.md").exists());
}
#[tokio::test]
#[cfg(unix)]
async fn symlinked_file_escape_is_rejected() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("real.md");
tokio::fs::write(&outside_file, "outside").await.unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(&server_dir).await.unwrap();
std::os::unix::fs::symlink(&outside_file, server_dir.join("SKILL.md")).unwrap();
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::Escape { .. }));
}
#[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.md");
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("SKILL.md")).unwrap();
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::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_skill_output_path(
base.path(),
"my-server",
Some(Path::new("not-a-dir/SKILL.md")),
)
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::NotADirectory { .. }));
}
#[tokio::test]
async fn final_path_that_is_an_existing_directory_is_rejected() {
let base = TempDir::new().unwrap();
let server_dir = base.path().join("my-server");
tokio::fs::create_dir_all(server_dir.join("SKILL.md"))
.await
.unwrap();
let err = resolve_skill_output_path(base.path(), "my-server", Some(Path::new("SKILL.md")))
.await
.unwrap_err();
assert!(matches!(err, OutputPathError::NotAFile { .. }));
}
}