diff --git a/src/sed/compiler.rs b/src/sed/compiler.rs index e5a61f0..b5c6de6 100644 --- a/src/sed/compiler.rs +++ b/src/sed/compiler.rs @@ -1332,6 +1332,104 @@ fn compile_text_command_posix( Ok(CommandHandling::Continue) } +// Handles e +// With no argument, the command executes the pattern space as a shell +// command at runtime. With an argument, the rest of the line is the +// command to run, following the same escape and backslash-newline +// continuation rules as the GNU a/c/i text argument. +fn compile_execute_command( + lines: &mut ScriptLineProvider, + line: &mut ScriptCharProvider, + cmd: &mut Command, + context: &mut ProcessingContext, +) -> UResult { + if context.posix || context.sandbox { + return compilation_error( + lines, + line, + "the 'e' command is not allowed with --posix or --sandbox", + ); + } + + line.advance(); // Skip the command character. + line.eat_spaces(); // Skip any leading whitespace. + + if line.eol() { + // No argument: execute the pattern space itself at runtime. + cmd.data = CommandData::None; + return Ok(CommandHandling::Continue); + } + + // True after a \ at the end of a line + let mut escaped_newline = false; + + // Skip optional \ + if line.current() == '\\' { + line.advance(); + escaped_newline = line.eol(); + } + + // Gather the command text. Stop on a non-escaped newline. Unlike most + // other commands, ';' does not terminate the argument. The rest of the + // (possibly continued) line is consumed unconditionally. + let mut text = String::new(); + // True once a continuation line has actually been pulled in. A dangling + // leading backslash with no line to continue into is treated as no + // argument at all, matching GNU sed. However once a continuation succeeds, + // even into an empty line, we're committed to producing a (possibly empty) + // Text argument from then on. + let mut continued = false; + 'text_content: loop { + if escaped_newline { + match lines.next_line()? { + None => { + break 'text_content; + } + Some(line_string) => { + *line = ScriptCharProvider::new(&line_string); + continued = true; + } + } + escaped_newline = false; + } + + // Non-escaped newline + if line.eol() { + break 'text_content; + } + + if line.current() == '\\' { + line.advance(); + + if line.eol() { + escaped_newline = true; + text.push('\n'); + continue 'text_content; + } + + if let Some(decoded) = parse_char_escape(line) { + text.push(decoded); + } else { + // Invalid escapes result in the escaped character. + text.push(line.current()); + line.advance(); + } + } else { + text.push(line.current()); + line.advance(); + } + } + + cmd.data = if text.is_empty() && !continued { + // A dangling leading backslash with nothing left to continue into. + // Treat this the same as no argument at all. + CommandData::None + } else { + CommandData::Text(Rc::from(text)) + }; + Ok(CommandHandling::Continue) +} + // Return the specification for the command letter at the current line position // checking for diverse errors. fn get_verified_cmd_spec( @@ -1422,6 +1520,11 @@ fn get_cmd_spec( n_addr: 1, handler: compile_number_command, }), + // e is a GNU extension + 'e' => Ok(CommandSpec { + n_addr: 2, + handler: compile_execute_command, + }), 'r' => Ok(CommandSpec { n_addr: if posix { 1 } else { 2 }, handler: compile_read_file_command, diff --git a/src/sed/delimited_parser.rs b/src/sed/delimited_parser.rs index 344e2a2..8a1455a 100644 --- a/src/sed/delimited_parser.rs +++ b/src/sed/delimited_parser.rs @@ -157,7 +157,7 @@ pub fn parse_char_escape(line: &mut ScriptCharProvider) -> Option { } 'U' => { - // Short Unicode escape \UXXXXXXXX (exactly eight heax digits) + // Short Unicode escape \UXXXXXXXX (exactly eight hex digits) line.advance(); // move past 'x' match parse_numeric_escape(line, |c| c.is_ascii_hexdigit(), 8, 16) { Some(decoded) => Some(decoded), diff --git a/src/sed/processor.rs b/src/sed/processor.rs index ba32998..6b5128b 100644 --- a/src/sed/processor.rs +++ b/src/sed/processor.rs @@ -19,7 +19,7 @@ use crate::sed::named_writer; use std::borrow::Cow; use std::cell::RefCell; -use std::io::{self, IsTerminal}; +use std::io::{self, IsTerminal, Read}; use std::path::PathBuf; use std::rc::Rc; use uucore::display::Quotable; @@ -215,6 +215,60 @@ fn shell_command(_cmd: &str) -> std::process::Command { unimplemented!("the 'e' substitute flag requires a platform shell (/bin/sh or cmd.exe)"); } +/// Run `cmd` in a shell, returning its standard output. The child's +/// standard error is left connected to this process's own, matching GNU +/// sed's behavior of letting shell errors surface directly rather than +/// being silently captured. +fn shell_stdout(cmd: &str) -> io::Result> { + let mut child = shell_command(cmd) + .stdout(std::process::Stdio::piped()) + .spawn()?; + let mut stdout = child.stdout.take().expect("stdout should be piped"); + let mut buf = Vec::new(); + stdout.read_to_end(&mut buf)?; + child.wait()?; + Ok(buf) +} + +/// Execute the pattern space as a shell command, replacing its contents +/// with the command's standard output, minus one trailing newline. +fn execute_pattern_as_shell_command( + pattern: &mut IOChunk, + command: &Command, + context: &mut ProcessingContext, +) -> UResult<()> { + let cmd_str = pattern.as_str()?.to_string(); + let stdout_bytes = shell_stdout(&cmd_str).map_err(|e| { + input_runtime_error::<()>( + &command.location, + context, + format!("failed to execute shell command: {e}"), + ) + .unwrap_err() + })?; + let mut shell_out = String::from_utf8(stdout_bytes).map_err(|e| { + input_runtime_error::<()>( + &command.location, + context, + format!("shell command output is not valid UTF-8: {e}"), + ) + .unwrap_err() + })?; + #[cfg(windows)] + { + if shell_out.ends_with("\r\n") { + // On Windows a trailing \r\n is the line terminator; strip both. + shell_out.truncate(shell_out.len() - 2); + } + } + // Unix (and some Windows tool) has the trailing \n. Strip it, as GNU sed does. + if shell_out.ends_with('\n') { + shell_out.pop(); + } + pattern.set_to_string(shell_out, pattern.is_newline_terminated()); + Ok(()) +} + /// Perform the specified RE replacement in the provided pattern space. fn substitute( pattern: &mut IOChunk, @@ -328,24 +382,7 @@ fn substitute( // Execute the pattern space as a shell command if the 'e' flag is set if sub.execute { - let cmd_str = pattern.as_str()?.to_string(); - let output_bytes = shell_command(&cmd_str).output().map_err(|e| { - input_runtime_error::<()>( - &command.location, - context, - format!("failed to execute shell command: {e}"), - ) - .unwrap_err() - })?; - let mut shell_out = String::from_utf8_lossy(&output_bytes.stdout).into_owned(); - if shell_out.ends_with("\r\n") { - // On windows, both return carriage and newline characters are used - shell_out.truncate(shell_out.len() - 2); - } else if shell_out.ends_with('\n') { - // Strip the trailing newline, as GNU sed does - shell_out.pop(); - } - pattern.set_to_string(shell_out, pattern.is_newline_terminated()); + execute_pattern_as_shell_command(pattern, command, context)?; } if sub.print_flag { @@ -581,6 +618,31 @@ fn process_file( pattern.clear(); break; } + 'e' => match &command.data { + CommandData::None => { + execute_pattern_as_shell_command(&mut pattern, &command, context)?; + } + CommandData::Text(cmd_str) => { + let stdout_bytes = shell_stdout(cmd_str).map_err(|e| { + input_runtime_error::<()>( + &command.location, + context, + format!("failed to execute shell command: {e}"), + ) + .unwrap_err() + })?; + let shell_out = String::from_utf8(stdout_bytes).map_err(|e| { + input_runtime_error::<()>( + &command.location, + context, + format!("shell command output is not valid UTF-8: {e}"), + ) + .unwrap_err() + })?; + output.write_str(shell_out)?; + } + _ => panic!("invalid 'e' command data"), + }, 'g' => { // Replace pattern with the contents of the hold space. pattern.set_to_string(context.hold.content.clone(), context.hold.has_newline); diff --git a/tests/by-util/test_sed.rs b/tests/by-util/test_sed.rs index 5fbb0fe..1cd0e23 100644 --- a/tests/by-util/test_sed.rs +++ b/tests/by-util/test_sed.rs @@ -668,6 +668,288 @@ fn test_subst_e_flag_no_match_no_exec() { .stdout_is("hello\n"); } +//////////////////////////////////////////////////////////// +// e command (execute) +// The with-argument form writes the shell's raw, unmodified output to the +// stream, so its byte-exact terminator differs by platform: LF from /bin/sh +// on Unix, CRLF from cmd.exe on Windows. sed's own pattern-space auto-print +// always uses LF. Hence the parallel Unix/Windows tests below. +#[cfg(unix)] +#[test] +fn test_e_command_with_arg_basic() { + // With an argument, the command runs immediately and its output is + // written to the stream before the (unmodified) pattern space. + new_ucmd!() + .arg("e echo hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\na\n"); +} + +#[cfg(windows)] +#[test] +fn test_e_command_with_arg_basic() { + new_ucmd!() + .arg("e echo hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\r\na\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_with_arg_no_space_required() { + // No whitespace is required between 'e' and its argument. + new_ucmd!() + .arg("eecho hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\na\n"); +} + +#[cfg(windows)] +#[test] +fn test_e_command_with_arg_no_space_required() { + new_ucmd!() + .arg("eecho hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\r\na\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_with_arg_runs_real_shell_command() -> Result<(), Box> { + // The argument is executed and not just its output captured. + // Verify a real filesystem side effect, matching GNU sed's own + // testsuite check for this command (testsuite/sandbox.sh). + let temp_dir = assert_fs::TempDir::new()?; + let marker = temp_dir.child("marker"); + + new_ucmd!() + .arg(format!("etouch {}", marker.path().display())) + .pipe_in("a\n") + .succeeds() + .stdout_is("a\n"); + + assert!(marker.path().exists()); + Ok(()) +} + +#[test] +fn test_e_command_no_arg_pattern_space_becomes_command() { + // With no argument, the pattern space itself is executed and replaced + // by the command's output. + new_ucmd!() + .arg("e") + .pipe_in("echo hi\n") + .succeeds() + .stdout_is("hi\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_no_arg_strips_one_trailing_newline() { + new_ucmd!() + .arg("e") + .pipe_in("printf \"a\\nb\\n\"\n") + .succeeds() + .stdout_is("a\nb\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_with_arg_does_not_strip_trailing_newline() { + // Unlike the no-argument form, e-with-argument writes the child's + // stdout unmodified (echo's own newline is preserved). + new_ucmd!() + .arg("e echo hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\na\n"); +} + +#[cfg(windows)] +#[test] +fn test_e_command_with_arg_does_not_strip_trailing_newline() { + // The preserved CRLF (rather than a stripped-then-LF "hi\n") is what + // proves the with-argument form leaves the child's output unmodified. + new_ucmd!() + .arg("e echo hi") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\r\na\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_with_address() { + new_ucmd!() + .arg("1e echo address") + .pipe_in("a\nb\n") + .succeeds() + .stdout_is("address\na\nb\n"); +} + +#[cfg(windows)] +#[test] +fn test_e_command_with_address() { + new_ucmd!() + .arg("1e echo address") + .pipe_in("a\nb\n") + .succeeds() + .stdout_is("address\r\na\nb\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_semicolon_is_part_of_argument() { + // Unlike most commands, ';' does not terminate e's argument. + // Rest of the line is consumed unconditionally, so the shell (not + // sed) is what splits on the ';' here. + new_ucmd!() + .arg("e echo hi; echo bye") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\nbye\na\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_recognized_escape_decoded() { + // Escape sequences in the argument are decoded by sed itself (\t is + // the tab character) before the shell ever sees them, same as a/c/i. + new_ucmd!() + .arg(r#"e echo "hi\tthere""#) + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\tthere\na\n"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_unrecognized_escape_falls_back_to_literal() { + // '\;' is not a recognized escape, so the backslash is dropped and + // ';' is kept literally. It's then the shell's own ';' that splits + // the resulting single-line command into two statements. + new_ucmd!() + .arg(r"e echo hi\;there") + .pipe_in("a\n") + .succeeds() + .stdout_is("hi\na\n") + .stderr_contains("there"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_backslash_continuation() { + // A trailing backslash continues the argument onto the next script + // line, joined by an embedded newline. The shell then treats that + // as two separate statements. + new_ucmd!() + .arg("e echo hi \\\nthere") + .pipe_in("x\n") + .succeeds() + .stdout_is("hi\nx\n") + .stderr_contains("there"); +} + +#[test] +fn test_e_command_dangling_backslash_falls_back_to_no_arg() { + // A leading backslash with nothing left to continue into at the end + // of the script is treated the same as no argument at all. + new_ucmd!() + .arg(r"e\") + .pipe_in("echo hi\n") + .succeeds() + .stdout_is("hi\n"); +} + +#[test] +fn test_e_command_rejected_with_posix() { + new_ucmd!() + .args(&["--posix", "e echo hi"]) + .fails() + .stderr_contains("not allowed with --posix or --sandbox"); +} + +#[test] +fn test_e_command_rejected_with_sandbox() { + new_ucmd!() + .args(&["--sandbox", "e echo hi"]) + .fails() + .stderr_contains("not allowed with --posix or --sandbox"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_rejected_with_sandbox_no_side_effect() -> Result<(), Box> { + // Rejection happens at compile time, before any input is processed, so + // the shell command must never run. + let temp_dir = assert_fs::TempDir::new()?; + let marker = temp_dir.child("marker"); + + new_ucmd!() + .args(&[ + "--sandbox", + "-e", + &format!("etouch {}", marker.path().display()), + ]) + .fails(); + + assert!(!marker.path().exists()); + Ok(()) +} + +#[test] +fn test_e_command_with_arg_command_failure() { + // A failing command's own error goes to stderr. sed itself succeeds. + new_ucmd!() + .arg("e nonexistent_command") + .pipe_in("a\n") + .succeeds() + .stdout_is("a\n") + .stderr_contains("nonexistent_command"); +} + +#[test] +fn test_e_command_no_arg_command_failure() { + new_ucmd!() + .arg("e") + .pipe_in("nonexistent_command\n") + .succeeds() + .stdout_is("\n") + .stderr_contains("nonexistent_command"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_no_arg_non_utf8_output_errors() { + // Non-UTF-8 shell output is reported as a runtime error (exit 2) + // rather than silently mangled. The pattern space is passed to the + // shell verbatim, so the shell's printf emits the raw 0xff byte. + new_ucmd!() + .arg("e") + .pipe_in("printf '\\377'\n") + .fails() + .code_is(2) + .stderr_contains("not valid UTF-8"); +} + +#[cfg(unix)] +#[test] +fn test_e_command_with_arg_non_utf8_output_errors() { + // Same for the with-argument form (a separate execution path). The + // doubled backslash survives sed's own escape decoding as a single + // one, so the shell's printf emits the raw 0xff byte. + new_ucmd!() + .arg(r"e printf '%b' '\\377'") + .pipe_in("a\n") + .fails() + .code_is(2) + .stderr_contains("not valid UTF-8"); +} + //////////////////////////////////////////////////////////// // Transliteration: y check_output!(trans_simple, ["-e", r"y/0123456789/9876543210/", LINES1]);