use rmcp::model::ErrorData;
use crate::shell_scan::{scan_backward_for_file_write, scan_backward_for_stdin_flag};
use crate::tools::common::error_meta;
pub(crate) fn validate_heredocs(command: &str, has_stdin: bool) -> Result<(), ErrorData> {
let bytes = command.as_bytes();
let len = bytes.len();
{
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0usize;
while i < len {
let ch = bytes[i] as char;
if ch == '\\' && !in_single_quote {
i += 2; continue;
}
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'<' {
if scan_backward_for_file_write(bytes, i) {
return Err(file_write_heredoc_error());
}
if scan_backward_for_stdin_flag(bytes, i) {
return Err(stdin_flag_heredoc_error());
}
if has_stdin {
return Err(stdin_param_heredoc_error());
}
i += 2;
continue;
}
i += 1;
}
}
{
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0usize;
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 stdin_flag_heredoc_error() -> ErrorData {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"stdin-consuming flag with heredoc detected (--body-file -, --data -, etc.) -- pass content via the `stdin` parameter instead, or write to a file first with edit_overwrite".to_string(),
Some(error_meta("validation", false, "use the stdin parameter instead of heredoc + stdin-consuming flags")),
)
}
fn file_write_heredoc_error() -> ErrorData {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"heredoc file-write pattern detected (cat/tee/redirect + <<) -- use edit_overwrite to write files instead of shell heredocs".to_string(),
Some(error_meta("validation", false, "use edit_overwrite to write files")),
)
}
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 stdin_param_heredoc_error() -> ErrorData {
ErrorData::new(
rmcp::model::ErrorCode::INVALID_PARAMS,
"stdin parameter and heredoc cannot be used together -- pass content via the `stdin` parameter instead".to_string(),
Some(error_meta("validation", false, "use the stdin parameter instead of a heredoc")),
)
}
#[cfg(test)]
mod tests {
use super::validate_heredocs;
#[test]
fn validate_heredocs_simple_heredoc_ok() {
let cmd = "cat <<EOF\nhello\nEOF\n";
assert!(validate_heredocs(cmd, false).is_ok());
}
#[test]
fn validate_heredocs_file_write_pattern_returns_error() {
let cmd = "tee output.txt <<EOF\ncontent\nEOF\n";
let err = validate_heredocs(cmd, false);
assert!(err.is_err());
let msg = format!("{}", err.unwrap_err().message);
assert!(msg.contains("heredoc file-write pattern"));
}
#[test]
fn validate_heredocs_stdin_flag_returns_error() {
let cmd = "curl --data - <<EOF\ncontent\nEOF\n";
let err = validate_heredocs(cmd, false);
assert!(err.is_err());
let msg = format!("{}", err.unwrap_err().message);
assert!(msg.contains("stdin-consuming flag"));
}
#[test]
fn validate_heredocs_stdin_param_returns_error() {
let cmd = "cat <<EOF\ncontent\nEOF\n";
let err = validate_heredocs(cmd, true);
assert!(err.is_err());
let msg = format!("{}", err.unwrap_err().message);
assert!(msg.contains("stdin parameter and heredoc"));
}
#[test]
fn validate_heredocs_empty_command_ok() {
assert!(validate_heredocs("", false).is_ok());
}
#[test]
fn validate_heredocs_no_heredoc_ok() {
assert!(validate_heredocs("echo hello", false).is_ok());
assert!(validate_heredocs("ls -la", false).is_ok());
}
}