use rmcp::model::ErrorData;
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(())
}
pub(crate) fn scan_backward_for_file_write(bytes: &[u8], here_pos: usize) -> bool {
fn token_before_pos<'a>(bytes: &'a [u8], pos: &mut usize) -> &'a [u8] {
let end = *pos;
while *pos > 0 {
let b = bytes[*pos - 1];
if b.is_ascii_whitespace() || b == b'(' || b == b'|' || b == b';' || b == b'&' {
break;
}
*pos -= 1;
}
&bytes[*pos..end]
}
fn paren_aware_token<'a>(bytes: &'a [u8], pos: &mut usize) -> &'a [u8] {
let end = *pos;
let mut depth: i32 = 0;
while *pos > 0 {
let b = bytes[*pos - 1];
if b == b')' {
depth += 1;
*pos -= 1;
} else if b == b'(' {
depth -= 1;
*pos -= 1;
if depth == 0 {
if *pos > 0 && matches!(bytes[*pos - 1], b'$' | b'>' | b'<') {
*pos -= 1;
}
continue;
}
} else if depth > 0 {
*pos -= 1;
} else if b.is_ascii_whitespace() || b == b'(' || b == b'|' || b == b';' || b == b'&' {
break;
} else {
*pos -= 1;
}
}
&bytes[*pos..end]
}
fn skip_ws_backward(bytes: &[u8], pos: &mut usize) {
while *pos > 0 && (bytes[*pos - 1] as char).is_ascii_whitespace() {
*pos -= 1;
}
}
fn is_file_write_command(cmd: &[u8]) -> bool {
cmd == b"cat"
|| cmd == b"tee"
|| cmd == b"printf"
|| cmd == b"dd"
|| cmd.first() == Some(&b'$')
}
fn scan_args_for_write_command(bytes: &[u8], pos: &mut usize) -> bool {
loop {
let tok = token_before_pos(bytes, pos);
if tok.is_empty() {
return false;
}
if is_file_write_command(tok) {
return true;
}
skip_ws_backward(bytes, pos);
if *pos == 0 {
return false;
}
let next = bytes[*pos - 1];
if next == b'|' || next == b';' || next == b'&' || next == b'(' {
return false;
}
}
}
let mut pos = here_pos;
skip_ws_backward(bytes, &mut pos);
if pos == 0 {
return false;
}
let file_token = paren_aware_token(bytes, &mut pos);
if file_token.is_empty() {
return false;
}
skip_ws_backward(bytes, &mut pos);
if pos == 0 {
return false;
}
if pos >= 2 && bytes[pos - 1] == b'>' && bytes[pos - 2] == b'>' {
pos -= 2;
skip_ws_backward(bytes, &mut pos);
if pos == 0 {
return true;
}
if scan_args_for_write_command(bytes, &mut pos) {
return true;
}
}
if bytes[pos - 1] == b'>' {
pos -= 1;
skip_ws_backward(bytes, &mut pos);
if pos == 0 {
return true;
}
if scan_args_for_write_command(bytes, &mut pos) {
return true;
}
}
let cmd = token_before_pos(bytes, &mut pos);
if is_file_write_command(cmd) {
return true;
}
if cmd.len() > 1 && cmd[0] == b'-' {
skip_ws_backward(bytes, &mut pos);
if pos == 0 {
return false;
}
let prev_cmd = token_before_pos(bytes, &mut pos);
return is_file_write_command(prev_cmd);
}
false
}
fn prev_token(bytes: &[u8], end: usize) -> &[u8] {
let mut pos = end;
let mut depth = 0i32;
loop {
if pos == 0 {
break;
}
let b = bytes[pos - 1];
if b == b')' {
depth -= 1;
pos = pos.saturating_sub(1);
continue;
}
if depth < 0 {
pos -= 1;
break;
}
if depth > 0 {
pos -= 1;
} else if b.is_ascii_whitespace() || b == b'(' || b == b'|' || b == b';' || b == b'&' {
break;
} else {
pos -= 1;
}
}
&bytes[pos..end]
}
fn is_stdin_consuming_flag(tok: &[u8]) -> bool {
tok == b"--body-file"
|| tok == b"--data"
|| tok == b"--data-raw"
|| tok == b"--data-binary"
|| tok == b"--data-urlencode"
|| tok == b"-d"
|| tok == b"-F"
|| tok == b"--stdin"
}
fn scan_backward_for_stdin_flag(bytes: &[u8], here_pos: usize) -> bool {
let mut pos = here_pos;
while pos > 0 && (bytes[pos - 1] as char).is_ascii_whitespace() {
pos -= 1;
}
if pos == 0 {
return false;
}
let tok = prev_token(bytes, pos);
pos -= tok.len();
if tok == b"-" {
while pos > 0 && (bytes[pos - 1] as char).is_ascii_whitespace() {
pos -= 1;
}
if pos == 0 {
return false;
}
let flag = prev_token(bytes, pos);
if is_stdin_consuming_flag(flag) {
return true;
}
if flag == b"cat" {
return true;
}
return false;
}
if is_stdin_consuming_flag(tok) {
return true;
}
false
}
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::scan_backward_for_file_write;
#[test]
fn scan_backward_empty_input_not_file_write() {
assert!(!scan_backward_for_file_write(b"", 0));
}
#[test]
fn scan_backward_only_whitespace_before_heredoc_not_file_write() {
let cmd = b" <<";
assert!(!scan_backward_for_file_write(cmd, 3));
}
#[test]
fn scan_backward_leading_whitespace_file_token_then_redirect() {
let cmd = b" cat > file <<";
assert!(scan_backward_for_file_write(cmd, 13));
}
#[test]
fn scan_backward_file_token_only_no_redirect_no_tee_not_file_write() {
let cmd = b"somecmd file <<";
assert!(!scan_backward_for_file_write(cmd, 13));
}
}