use rmcp::model::ErrorData;
use crate::error_meta;
pub(crate) fn validate_heredocs(command: &str) -> Result<(), ErrorData> {
let bytes = command.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
while i < len {
let ch = bytes[i] as char;
if ch == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
i += 1;
continue;
}
if ch == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
i += 1;
continue;
}
if in_single_quote || in_double_quote {
i += 1;
continue;
}
if ch == '<' && i + 1 < len && bytes[i + 1] == b'<' {
let _here_start = i;
i += 2;
let strip_tabs = if i < len && bytes[i] == b'-' {
i += 1;
true
} else {
false
};
while i < len && (bytes[i] as char).is_ascii_whitespace() {
i += 1;
}
if i >= len {
return Err(missing_heredoc_error());
}
let delimiter = if bytes[i] == b'\'' {
i += 1;
let start = i;
while i < len && bytes[i] != b'\'' {
i += 1;
}
if i >= len {
return Err(missing_heredoc_error());
}
let word = &command[start..i];
i += 1; word.to_string()
} else if bytes[i] == b'"' {
i += 1;
let start = i;
while i < len && bytes[i] != b'"' {
i += 1;
}
if i >= len {
return Err(missing_heredoc_error());
}
let word = &command[start..i];
i += 1; word.to_string()
} else if bytes[i] == b'\\' {
i += 1;
let start = i;
while i < len && !(bytes[i] as char).is_ascii_whitespace() && bytes[i] != b'<' {
i += 1;
}
command[start..i].to_string()
} else {
let start = i;
while i < len && !(bytes[i] as char).is_ascii_whitespace() && bytes[i] != b'<' {
i += 1;
}
command[start..i].to_string()
};
if delimiter.is_empty() {
return Err(missing_heredoc_error());
}
let mut found = false;
let rest = &command[i..];
let mut consumed = i;
for raw_line in rest.split_inclusive('\n') {
let line = raw_line.trim_end_matches('\n');
let candidate = if strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
if candidate == delimiter {
found = true;
i = consumed + raw_line.len();
break;
}
consumed += raw_line.len();
}
if !found {
return Err(missing_heredoc_error());
}
} else {
i += 1;
}
}
Ok(())
}
fn missing_heredoc_error() -> ErrorData {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"heredoc closing delimiter not found -- likely a quoting or escaping issue; use edit_overwrite to write files instead of shell heredocs".to_string(),
Some(error_meta("validation", false, "use edit_overwrite to write files")),
)
}
fn validate_parent_in_root(
path: &str,
root: &std::path::Path,
) -> Result<std::path::PathBuf, ErrorData> {
let p = std::path::Path::new(path);
let file_name = p.file_name().ok_or_else(|| {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path must include a filename component".to_string(),
Some(error_meta(
"validation",
false,
"provide a path with a filename, not ending in '..' or '/'",
)),
)
})?;
let parent = p.parent().unwrap_or(std::path::Path::new(""));
let parent_path = if parent.as_os_str().is_empty() || parent == std::path::Path::new(".") {
root.to_path_buf()
} else {
root.join(parent)
};
let canonical_parent = std::fs::canonicalize(&parent_path).map_err(|e| {
io_error_to_path_error(
&e,
parent.to_str().unwrap_or("(invalid utf-8)"),
"provide a valid parent directory within the working directory",
)
})?;
if !canonical_parent.starts_with(root) {
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a path within the working directory",
)),
));
}
if !std::fs::metadata(&canonical_parent)
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"parent path is not a directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a path whose parent is a directory",
)),
));
}
let resolved_path = canonical_parent.join(file_name);
if !resolved_path.starts_with(root) {
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a path within the working directory",
)),
));
}
Ok(resolved_path)
}
pub(crate) fn validate_path(
path: &str,
require_exists: bool,
) -> Result<std::path::PathBuf, ErrorData> {
let cwd = std::env::current_dir().map_err(|_| {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"ensure the working directory is accessible",
)),
)
})?;
let allowed_root = std::fs::canonicalize(&cwd).map_err(|_| {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"ensure the working directory is accessible",
)),
)
})?;
let canonical_path = if require_exists {
std::fs::canonicalize(path).map_err(|e| {
let msg = match e.kind() {
std::io::ErrorKind::NotFound => "path not found".to_string(),
std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
_ => "path is outside the working directory".to_string(),
};
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
msg,
Some(error_meta(
"validation",
false,
"provide a valid path within the working directory",
)),
)
})?
} else {
validate_parent_in_root(path, &allowed_root)?
};
if !canonical_path.starts_with(&allowed_root) {
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a path within the current working directory",
)),
));
}
Ok(canonical_path)
}
pub(crate) fn io_error_to_path_error(
err: &std::io::Error,
path_context: &str,
suggested_action: &'static str,
) -> ErrorData {
let msg = match err.kind() {
std::io::ErrorKind::NotFound => format!("path not found: {path_context}"),
std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
_ => format!("path is invalid: {path_context}"),
};
let mut meta = error_meta("validation", false, suggested_action);
if let Some(obj) = meta.as_object_mut() {
obj.insert(
"ioErrorKind".to_string(),
serde_json::json!(format!("{:?}", err.kind())),
);
obj.insert(
"ioErrorSource".to_string(),
serde_json::json!(err.to_string()),
);
}
ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
}
pub(crate) fn validate_path_in_dir(
path: &str,
require_exists: bool,
working_dir: &std::path::Path,
) -> Result<std::path::PathBuf, ErrorData> {
let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
})?;
if !std::fs::metadata(&canonical_working_dir)
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"working_dir must be a directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a valid directory path",
)),
));
}
let canonical_path = if require_exists {
let target_path = canonical_working_dir.join(path);
std::fs::canonicalize(&target_path).map_err(|e| {
io_error_to_path_error(
&e,
path,
"provide a valid path within the working directory",
)
})?
} else {
validate_parent_in_root(path, &canonical_working_dir)?
};
if !canonical_path.starts_with(&canonical_working_dir) {
return Err(ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"path is outside the working directory".to_string(),
Some(error_meta(
"validation",
false,
"provide a path within the working directory",
)),
));
}
Ok(canonical_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_path_no_trailing_slash() {
let input = "subdir/new_file.txt";
let result = validate_path(input, false);
if let Ok(resolved) = result {
let path_str = resolved.to_string_lossy();
assert!(
!path_str.ends_with('/'),
"resolved path must not end with trailing slash: {path_str}"
);
assert_eq!(
resolved.extension(),
Some(std::ffi::OsStr::new("txt")),
"file extension should be txt, path has trailing separator"
);
}
}
}