use std::convert::TryFrom; use std::env; use std::fs::File; use std::io::{BufRead, BufReader, Cursor, Read}; use std::path::PathBuf; use std::process::{ExitStatus, Stdio}; use clircle::{Clircle, Identifier}; use execute::{shell, Execute}; use crate::error::Result; use crate::{ bat_warning, input::{Input, InputKind, InputReader, OpenedInput, OpenedInputKind}, }; /// Wrap `s` in POSIX single quotes. An embedded `'` is encoded as `'\''` /// (close, escaped quote, reopen) since `'...'` has no escape character. fn shell_quote(s: &str) -> String { if !s.contains('\'') { let mut quoted = String::with_capacity(s.len() + 2); quoted.push('\''); quoted.push_str(s); quoted.push('\''); return quoted; } let mut quoted = String::with_capacity(s.len() + 2); quoted.push('\''); for c in s.chars() { if c == '\'' { quoted.push_str("'\\''"); } else { quoted.push(c); } } quoted.push('\''); quoted } /// Substitute the first `%s` in a $LESSOPEN/$LESSCLOSE template with a quoted value. fn shell_substitute(template: &str, replacement: &str) -> String { template.replacen("%s", &shell_quote(replacement), 1) } /// Substitute the first two `%s` occurrences positionally in one pass; chaining /// `shell_substitute` twice would mis-target if `first` itself contains `%s`. fn shell_substitute_two(template: &str, first: &str, second: &str) -> String { let mut out = String::with_capacity(template.len() + first.len() + second.len() + 4); let mut remaining = template; let mut to_substitute = [Some(first), Some(second)]; for slot in &mut to_substitute { if let Some(idx) = remaining.find("%s") { out.push_str(&remaining[..idx]); out.push_str(&shell_quote(slot.take().unwrap())); remaining = &remaining[idx + 2..]; } else { break; } } out.push_str(remaining); out } /// Preprocess files and/or stdin using $LESSOPEN and $LESSCLOSE pub(crate) struct LessOpenPreprocessor { lessopen: String, lessclose: Option, kind: LessOpenKind, /// Whether or not data piped via stdin is to be preprocessed preprocess_stdin: bool, } enum LessOpenKind { Piped, PipedIgnoreExitCode, TempFile, } impl LessOpenPreprocessor { /// Create a new instance of LessOpenPreprocessor /// Will return Ok if and only if $LESSOPEN is set and contains exactly one %s pub(crate) fn new() -> Result { let lessopen = env::var("LESSOPEN")?; // Ignore $LESSOPEN if it does not contains exactly one %s // Note that $LESSCLOSE has no such requirement if lessopen.match_indices("%s").count() != 1 { let error_msg = "LESSOPEN ignored: must contain exactly one %s"; bat_warning!("{error_msg}"); return Err(error_msg.into()); } // "||" means pipe directly to bat without making a temporary file // Also, if preprocessor output is empty and exit code is zero, use the empty output // Otherwise, if output is empty and exit code is nonzero, use original file contents let (kind, lessopen) = if lessopen.starts_with("||") { (LessOpenKind::Piped, lessopen.chars().skip(2).collect()) // "|" means pipe as above, but ignore exit code and always use preprocessor output even if empty } else if lessopen.starts_with('|') { ( LessOpenKind::PipedIgnoreExitCode, lessopen.chars().skip(1).collect(), ) // If neither appear, write output to a temporary file and read from that } else { (LessOpenKind::TempFile, lessopen) }; // "-" means that stdin is preprocessed along with files and may appear alongside "|" and "||" let (stdin, lessopen) = if lessopen.starts_with('-') { (true, lessopen.chars().skip(1).collect()) } else { (false, lessopen) }; Ok(Self { lessopen, lessclose: env::var("LESSCLOSE").ok(), kind, preprocess_stdin: stdin, }) } pub(crate) fn open<'a, R: BufRead + 'a>( &self, input: Input<'a>, mut stdin: R, stdout_identifier: Option<&Identifier>, ) -> Result> { let (lessopen_stdout, path_str, kind) = match input.kind { InputKind::OrdinaryFile(ref path) => { let path_str = match path.to_str() { Some(str) => str, None => return input.open(stdin, stdout_identifier), }; let mut lessopen_command = shell(shell_substitute(&self.lessopen, path_str)); lessopen_command.stdout(Stdio::piped()); let lessopen_output = match lessopen_command.execute_output() { Ok(output) => output, Err(_) => return input.open(stdin, stdout_identifier), }; if self.fall_back_to_original_file(&lessopen_output.stdout, lessopen_output.status) { return input.open(stdin, stdout_identifier); } ( lessopen_output.stdout, path_str.to_string(), OpenedInputKind::OrdinaryFile(path.to_path_buf()), ) } InputKind::StdIn => { if self.preprocess_stdin { if let Some(stdout) = stdout_identifier { let input_identifier = Identifier::try_from(clircle::Stdio::Stdin) .map_err(|e| format!("Stdin: Error identifying file: {e}"))?; if stdout.surely_conflicts_with(&input_identifier) { return Err("IO circle detected. The input from stdin is also an output. Aborting to avoid infinite loop.".into()); } } // stdin isn't Clone or AsRef<[u8]>, so move it into a cloneable buffer // so the data can be used multiple times if necessary // NOTE: stdin will be empty from this point onwards let mut stdin_buffer = Vec::new(); stdin.read_to_end(&mut stdin_buffer)?; let mut lessopen_command = shell(shell_substitute(&self.lessopen, "-")); lessopen_command.stdout(Stdio::piped()); let lessopen_output = match lessopen_command.execute_input_output(&stdin_buffer) { Ok(output) => output, Err(_) => { return input.open(Cursor::new(stdin_buffer), stdout_identifier); } }; if self .fall_back_to_original_file(&lessopen_output.stdout, lessopen_output.status) { return input.open(Cursor::new(stdin_buffer), stdout_identifier); } ( lessopen_output.stdout, "-".to_string(), OpenedInputKind::StdIn, ) } else { return input.open(stdin, stdout_identifier); } } InputKind::CustomReader(_) => { return input.open(stdin, stdout_identifier); } }; Ok(OpenedInput { kind, reader: InputReader::try_new(BufReader::new( if matches!(self.kind, LessOpenKind::TempFile) { let lessopen_string = match String::from_utf8(lessopen_stdout) { Ok(string) => string, Err(_) => { return input.open(stdin, stdout_identifier); } }; // Remove newline at end of temporary file path returned by $LESSOPEN let stdout = match lessopen_string.strip_suffix("\n") { Some(stripped) => stripped.to_owned(), None => lessopen_string, }; let file = match File::open(PathBuf::from(&stdout)) { Ok(file) => file, Err(_) => { return input.open(stdin, stdout_identifier); } }; Preprocessed { kind: PreprocessedKind::TempFile(file), lessclose: self .lessclose .as_ref() .map(|s| shell_substitute_two(s, &path_str, &stdout)), } } else { Preprocessed { kind: PreprocessedKind::Piped(Cursor::new(lessopen_stdout)), lessclose: self .lessclose .as_ref() .map(|s| shell_substitute_two(s, &path_str, "-")), } }, ))?, metadata: input.metadata, description: input.description, }) } fn fall_back_to_original_file(&self, lessopen_stdout: &[u8], exit_code: ExitStatus) -> bool { lessopen_stdout.is_empty() && (!exit_code.success() || matches!(self.kind, LessOpenKind::PipedIgnoreExitCode)) } #[cfg(test)] /// For testing purposes only /// Create an instance of LessOpenPreprocessor with specified valued for $LESSOPEN and $LESSCLOSE fn mock_new(lessopen: Option<&str>, lessclose: Option<&str>) -> Result { if let Some(command) = lessopen { env::set_var("LESSOPEN", command) } else { env::remove_var("LESSOPEN") } if let Some(command) = lessclose { env::set_var("LESSCLOSE", command) } else { env::remove_var("LESSCLOSE") } Self::new() } } enum PreprocessedKind { Piped(Cursor>), TempFile(File), } impl Read for PreprocessedKind { fn read(&mut self, buf: &mut [u8]) -> std::result::Result { match self { PreprocessedKind::Piped(data) => data.read(buf), PreprocessedKind::TempFile(data) => data.read(buf), } } } pub struct Preprocessed { kind: PreprocessedKind, lessclose: Option, } impl Read for Preprocessed { fn read(&mut self, buf: &mut [u8]) -> std::result::Result { self.kind.read(buf) } } impl Drop for Preprocessed { fn drop(&mut self) { if let Some(lessclose) = self.lessclose.clone() { let mut lessclose_command = shell(lessclose); let lessclose_output = match lessclose_command.execute_output() { Ok(output) => output, Err(_) => { bat_warning!("failed to run $LESSCLOSE to clean up temporary file"); return; } }; if !lessclose_output.status.success() { bat_warning!("$LESSCLOSE exited with nonzero exit code",) }; } } } #[cfg(test)] mod tests { // All tests here are serial because they all involve reading and writing environment variables // Running them in parallel causes these tests and some others to randomly fail use serial_test::serial; use super::*; /// Reset environment variables after each test as a precaution fn reset_env_vars() { env::remove_var("LESSOPEN"); env::remove_var("LESSCLOSE"); } #[test] #[serial] fn test_just_lessopen() -> Result<()> { let preprocessor = LessOpenPreprocessor::mock_new(Some("|batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(preprocessor.lessclose.is_none()); reset_env_vars(); Ok(()) } #[test] #[serial] fn test_just_lessclose() -> Result<()> { let preprocessor = LessOpenPreprocessor::mock_new(None, Some("lessclose.sh %s %s")); assert!(preprocessor.is_err()); reset_env_vars(); Ok(()) } #[test] #[serial] fn test_both_lessopen_and_lessclose() -> Result<()> { let preprocessor = LessOpenPreprocessor::mock_new(Some("lessopen.sh %s"), Some("lessclose.sh %s %s"))?; assert_eq!(preprocessor.lessopen, "lessopen.sh %s"); assert_eq!(preprocessor.lessclose.unwrap(), "lessclose.sh %s %s"); reset_env_vars(); Ok(()) } #[test] #[serial] fn test_lessopen_prefixes() -> Result<()> { let preprocessor = LessOpenPreprocessor::mock_new(Some("batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!(preprocessor.kind, LessOpenKind::TempFile)); assert!(!preprocessor.preprocess_stdin); let preprocessor = LessOpenPreprocessor::mock_new(Some("|batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!( preprocessor.kind, LessOpenKind::PipedIgnoreExitCode )); assert!(!preprocessor.preprocess_stdin); let preprocessor = LessOpenPreprocessor::mock_new(Some("||batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!(preprocessor.kind, LessOpenKind::Piped)); assert!(!preprocessor.preprocess_stdin); let preprocessor = LessOpenPreprocessor::mock_new(Some("-batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!(preprocessor.kind, LessOpenKind::TempFile)); assert!(preprocessor.preprocess_stdin); let preprocessor = LessOpenPreprocessor::mock_new(Some("|-batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!( preprocessor.kind, LessOpenKind::PipedIgnoreExitCode )); assert!(preprocessor.preprocess_stdin); let preprocessor = LessOpenPreprocessor::mock_new(Some("||-batpipe %s"), None)?; assert_eq!(preprocessor.lessopen, "batpipe %s"); assert!(matches!(preprocessor.kind, LessOpenKind::Piped)); assert!(preprocessor.preprocess_stdin); reset_env_vars(); Ok(()) } #[test] #[serial] fn replace_part_of_argument() -> Result<()> { let preprocessor = LessOpenPreprocessor::mock_new(Some("|echo File:%s"), Some("echo File:%s Temp:%s"))?; assert_eq!(preprocessor.lessopen, "echo File:%s"); assert_eq!(preprocessor.lessclose.unwrap(), "echo File:%s Temp:%s"); reset_env_vars(); Ok(()) } #[test] fn shell_quote_plain_filename() { assert_eq!(super::shell_quote("file.txt"), "'file.txt'"); assert_eq!(super::shell_quote("with space.txt"), "'with space.txt'"); assert_eq!(super::shell_quote(""), "''"); assert_eq!(super::shell_quote("-"), "'-'"); } #[test] fn shell_quote_metacharacters_neutralised() { assert_eq!(super::shell_quote("; rm -rf ~ ;"), "'; rm -rf ~ ;'"); assert_eq!(super::shell_quote("$(payload)"), "'$(payload)'"); assert_eq!(super::shell_quote("`payload`"), "'`payload`'"); assert_eq!(super::shell_quote("a|b&c;d"), "'a|b&c;d'"); assert_eq!(super::shell_quote("..>/dev/null"), "'..>/dev/null'"); assert_eq!(super::shell_quote("\n\t"), "'\n\t'"); } #[test] fn shell_quote_embedded_single_quote() { assert_eq!(super::shell_quote("it's"), "'it'\\''s'"); assert_eq!(super::shell_quote("'"), "''\\'''"); assert_eq!(super::shell_quote("a'b'c"), "'a'\\''b'\\''c'"); } #[test] fn shell_substitute_only_replaces_first_percent_s() { assert_eq!(super::shell_substitute("echo %s", "x"), "echo 'x'"); assert_eq!(super::shell_substitute("echo %s %s", "x"), "echo 'x' %s"); } #[test] fn shell_substitute_protects_against_filename_injection() { let template = "|preproc %s"; let attacker_filename = "; rm -rf ~ ;"; let result = super::shell_substitute(template, attacker_filename); assert_eq!(result, "|preproc '; rm -rf ~ ;'"); } #[test] fn shell_substitute_two_replaces_first_two_placeholders() { assert_eq!( super::shell_substitute_two("echo %s and %s done", "a", "b"), "echo 'a' and 'b' done" ); // A third %s is left alone. assert_eq!( super::shell_substitute_two("echo %s %s %s", "a", "b"), "echo 'a' 'b' %s" ); } #[test] fn shell_substitute_two_handles_percent_s_in_first_argument() { // The chained-substitute approach would replace the injected `%s` here. assert_eq!( super::shell_substitute_two("a %s b %s c", "%s", "second"), "a '%s' b 'second' c" ); } }