From dcec7ce5418aac753e827cfe96e044f30b1c7521 Mon Sep 17 00:00:00 2001 From: Lu Date: Sun, 2 Jul 2023 05:00:36 +0200 Subject: [PATCH] initial commit --- .gitignore | 4 + Cargo.toml | 11 +++ gnu_echo_tests/test.py | 68 ++++++++++++++++ src/consts.rs | 65 +++++++++++++++ src/main.rs | 177 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100755 gnu_echo_tests/test.py create mode 100644 src/consts.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc2a5e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.vscode +.idea +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9a31768 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "echo-rs" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ascii_converter = "0.3.0" +lazy_static = "1.4.0" +regex = "1.8.4" diff --git a/gnu_echo_tests/test.py b/gnu_echo_tests/test.py new file mode 100755 index 0000000..aa71b94 --- /dev/null +++ b/gnu_echo_tests/test.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +from pathlib import Path + + +def test_params(echo: Path, binary: Path, params: list[str]) -> None: + assert ( # noqa: S101 + subprocess.check_output( + [echo, *params]) == subprocess.check_output([binary, *params]) # noqa: S603 + ) + + +def test_newline(echo: Path, binary: Path) -> None: + test_params(echo, binary, ["-n", r"test"]) + test_params(echo, binary, [r"test"]) + + +def test_no_escapes(echo: Path, binary: Path) -> None: + test_params(echo, binary, [r"\\\a\b\c\e\f\n\r\t\v\00\x0"]) + + +def test_escapes(echo: Path, binary: Path) -> None: + test_params(echo, binary, ["-e", r"test\\test"]) + test_params(echo, binary, ["-e", r"test\atest"]) + test_params(echo, binary, ["-e", r"test\btest"]) + test_params(echo, binary, ["-e", r"test\ctest"]) + test_params(echo, binary, ["-e", r"test\etest"]) + test_params(echo, binary, ["-e", r"test\ftest"]) + test_params(echo, binary, ["-e", r"test\ntest"]) + test_params(echo, binary, ["-e", r"test\rtest"]) + test_params(echo, binary, ["-e", r"test\ttest"]) + test_params(echo, binary, ["-e", r"test\vtest"]) + + +def test_octal(echo: Path, binary: Path) -> None: + test_params(echo, binary, [ + "-e", "-n", r"\0\00\0000\777\0777", r"\377\0377\376\0376", r"\00000\1000\01000"]) + + +def test_hex(echo: Path, binary: Path) -> None: + test_params(echo, binary, ["-e", "-n", + r"\x\x0\x00\xFF", r"\xFE\x000\x100"]) + + +def test_octal_hex(echo: Path, binary: Path) -> None: + test_params(echo, binary, ["-e", "-n", r"\0\x\00\x0\0000\x00", + r"\777\0777\377\0377\xFF", r"\376\0376\xFE\00000\x000", r"\1000\01000\x100"]) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("-e", "--echo", help="gnu echo binary", type=Path) + parser.add_argument("-b", "--binary", help="custom echo binary", type=Path) + args = parser.parse_args() + echo = args.echo + binary = args.binary + + test_newline(echo, binary) + test_no_escapes(echo, binary) + test_escapes(echo, binary) + test_octal(echo, binary) + test_hex(echo, binary) + test_octal_hex(echo, binary) + + +if __name__ == "__main__": + main() diff --git a/src/consts.rs b/src/consts.rs new file mode 100644 index 0000000..a3eb7ab --- /dev/null +++ b/src/consts.rs @@ -0,0 +1,65 @@ +use std::process::exit; + +use lazy_static::lazy_static; +use regex::Regex; + +pub const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); +pub const HELP_DIALOG: &str = r#" +Usage: echo-rs [SHORT-OPTION]... [STRING]... + or: echo-rs LONG-OPTION +Echo the STRING(s) to standard output. Rust rewrite of GNU echo util. + + -n do not output the trailing newline + -e enable interpretation of backslash escapes + -E disable interpretation of backslash escapes (default) + --help display this help and exit + --version output version information and exit + +If -e is in effect, the following sequences are recognized: + + \\ backslash + \a alert (BEL) + \b backspace + \c produce no further output + \e escape + \f form feed + \n new line + \r carriage return + \t horizontal tab + \v vertical tab + \0NNN byte with octal value NNN (1 to 3 digits) + \xHH byte with hexadecimal value HH (1 to 2 digits) +"#; +pub const SIMPLE_SPECIAL_SEQUENCES: [(&str, &str); 8] = [ + (r#"\a"#, "\x07"), + (r#"\b"#, "\x08"), + (r#"\e"#, "\x1b"), + (r#"\f"#, "\x0c"), + (r#"\n"#, "\n"), + (r#"\r"#, "\r"), + (r#"\t"#, "\t"), + (r#"\v"#, "\x0b"), +]; +lazy_static! { + pub static ref OCTAL_REGEX: Regex = Regex::new(r#"\\(?:([1-7][0-7]{0,2}|0[0-7]{0,3}))"#) + .map_or_else( + |e| { + eprintln!( + "programming error: cannot compile regex pattern for octal regex match: {}", + e + ); + exit(1); + }, + |v| v + ); + pub static ref HEX_REGEX: Regex = Regex::new(r#"\\x([0-9A-F]{0,2})"#).map_or_else( + |e| { + eprintln!( + "programming error: cannot compile regex pattern for hex regex match: {}", + e + ); + exit(1); + }, + |v| v + ); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f648f34 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,177 @@ +use std::{ + env::args, + io::{self, Write}, + process::exit, +}; + +mod consts; + +use crate::consts::{HELP_DIALOG, HEX_REGEX, OCTAL_REGEX, SIMPLE_SPECIAL_SEQUENCES, VERSION}; + +struct Settings { + trailing_newline: bool, + interpret_backslash_escapes: bool, +} + +impl Default for Settings { + fn default() -> Settings { + Settings { + trailing_newline: true, + interpret_backslash_escapes: false, + } + } +} + +fn replace_octal(string: String) -> String { + let mut res = string; + while let Some(captures) = OCTAL_REGEX.captures( + #[allow(clippy::redundant_clone)] + &res.clone(), + ) { + if let (Some(entire_match), Some(capture)) = (captures.get(0), captures.get(1)) { + let mut contents = capture.as_str(); + if contents.is_empty() { + contents = "0" // GNU echo interprets \0 as \00 + } + res = format!( + "{}{}", + &res[..entire_match.start()], + OCTAL_REGEX.replace( + &res[entire_match.start()..], + &u8::from_str_radix(contents, 8) + .map_or_else(|_| 255 as char, |v| v as char) + .to_string(), + ) + ); + } else { + println!("error matching octal regex. aborting"); + exit(1); + } + } + res +} + +fn replace_hex(string: String) -> String { + let mut res = string; + let mut search_at = 0; + while let Some(captures) = HEX_REGEX.captures_at( + #[allow(clippy::redundant_clone)] + &res.clone(), + search_at, + ) { + if let (Some(entire_match), Some(capture)) = (captures.get(0), captures.get(1)) { + let contents = capture.as_str(); + if contents.is_empty() { + search_at = entire_match.end(); + continue; // GNU echo does not interpret \x + } + res = format!( + "{}{}", + &res[..entire_match.start()], + HEX_REGEX.replace( + &res[entire_match.start()..], + &u8::from_str_radix(contents, 16) + .map_or_else(|_| 255 as char, |v| v as char) + .to_string(), + ) + ); + } else { + println!("error matching hex regex. aborting"); + exit(1); + } + } + res +} + +fn format_arg(arg: String, settings: &mut Settings) -> String { + if settings.interpret_backslash_escapes { + let mut res = arg; + let mut backslash_escaped: Vec = + res.split(r#"\\"#).map(|s| s.to_string()).collect(); + let mut found_c = false; + for element in &mut backslash_escaped { + if found_c { + *element = String::new(); + continue; + } + + for sequence in SIMPLE_SPECIAL_SEQUENCES { + *element = element.replace(sequence.0, sequence.1) + } + + if let Some(pos) = element.find(r#"\c"#) { + found_c = true; + settings.trailing_newline = false; + *element = element[..pos].to_string(); + continue; + } + + *element = replace_octal(element.clone()); + *element = replace_hex(element.clone()); + } + res = backslash_escaped.join(r#"\"#); + res + } else { + arg + } +} + +fn write_as_unicode(string: String) { + let stdout = io::stdout(); + stdout + .lock() + .write_all( + string + .chars() + .map(|c| c as u8) + .collect::>() + .as_slice(), + ) + .map_or_else(|e| eprintln!("error writing to stdout: {e}"), |_| {}); +} + +fn main() { + let mut settings = Settings::default(); + let mut read_flags = true; + for i in 1..args().len() { + if let Some(arg) = args().nth(i) { + if read_flags { + match arg.as_str() { + "-n" => { + settings.trailing_newline = false; + } + "-e" => { + settings.interpret_backslash_escapes = true; + } + "-E" => { + settings.interpret_backslash_escapes = false; + } + "--help" => { + println!("{}", HELP_DIALOG); + exit(0) + } + "--version" => { + match VERSION { + Some(v) => { + println!("echo-rs {}", v) + } + None => { + println!("echo-rs was not compiled with a version") + } + } + exit(0) + } + _ => { + write_as_unicode(format_arg(arg, &mut settings)); + read_flags = false; + } + } + } else { + write_as_unicode(format!(" {}", format_arg(arg, &mut settings))); + } + } + } + if settings.trailing_newline { + println!() + } +}