From aca42f2411a25990fc3df2b8d779e9e1c4cce645 Mon Sep 17 00:00:00 2001 From: Liam <33645555+lj3954@users.noreply.github.com> Date: Sun, 22 Sep 2024 11:50:43 -0500 Subject: [PATCH] fix: Respect shebangs in scripts (#606) * Cargo will rebuild if anything changes in src/, recursively. E.g src/commands/ is also checked * No need to make a generic, we only use 1 backend * Delete the imports that are no longer needed * Replace the weird struct hack with an enum * Delete a useless line * The None should be explicit * Support for non-english keyboard input * Commit Linutil * refactor: Improve conciseness of char passthrough * fix: Respect shebang in script files * refactor: More efficiently handle shebangs * refactor: Remove unnecessary error handling If 2 characters can be read from the file, a line must exist * fix: Drop accidentally added file * fix: Ensure that executable exists before displaying entry * fix: Explicitly check if the executable is a file * refactor: Replace unnecessary import Co-authored-by: Adam Perkowski * fix: Check whether the file is directly executable * fix: Comply with rustfmt Co-authored-by: Adam Perkowski --------- Co-authored-by: Andrii Dokhniak Co-authored-by: JustLinuxUser Co-authored-by: Adam Perkowski --- core/src/inner.rs | 147 ++++++++++++++++++++++++------------- core/src/lib.rs | 7 +- tui/src/floating_text.rs | 7 +- tui/src/main.rs | 10 +-- tui/src/running_command.rs | 52 +++++++------ tui/src/state.rs | 2 +- 6 files changed, 141 insertions(+), 84 deletions(-) diff --git a/core/src/inner.rs b/core/src/inner.rs index bd45cc13..2e34954e 100644 --- a/core/src/inner.rs +++ b/core/src/inner.rs @@ -2,7 +2,12 @@ use crate::{Command, ListNode, Tab}; use ego_tree::{NodeMut, Tree}; use include_dir::{include_dir, Dir}; use serde::Deserialize; -use std::path::{Path, PathBuf}; +use std::{ + fs::File, + io::{BufRead, BufReader, Read}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; use tempdir::TempDir; const TAB_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/tabs"); @@ -37,7 +42,7 @@ pub fn get_tabs(validate: bool) -> Vec { task_list: String::new(), }); let mut root = tree.root_mut(); - create_directory(data, &mut root, &directory); + create_directory(data, &mut root, &directory, validate); Tab { name, tree, @@ -78,16 +83,22 @@ struct Entry { description: String, #[serde(default)] preconditions: Option>, - #[serde(default)] - entries: Option>, - #[serde(default)] - command: Option, - #[serde(default)] - script: Option, + #[serde(flatten)] + entry_type: EntryType, #[serde(default)] task_list: String, } +#[derive(Deserialize)] +enum EntryType { + #[serde(rename = "entries")] + Entries(Vec), + #[serde(rename = "command")] + Command(String), + #[serde(rename = "script")] + Script(PathBuf), +} + impl Entry { fn is_supported(&self) -> bool { self.preconditions.as_deref().map_or(true, |preconditions| { @@ -142,7 +153,7 @@ fn filter_entries(entries: &mut Vec) { if !entry.is_supported() { return false; } - if let Some(entries) = &mut entry.entries { + if let EntryType::Entries(entries) = &mut entry.entry_type { filter_entries(entries); !entries.is_empty() } else { @@ -151,53 +162,89 @@ fn filter_entries(entries: &mut Vec) { }); } -fn create_directory(data: Vec, node: &mut NodeMut, command_dir: &Path) { +fn create_directory( + data: Vec, + node: &mut NodeMut, + command_dir: &Path, + validate: bool, +) { for entry in data { - if [ - entry.entries.is_some(), - entry.command.is_some(), - entry.script.is_some(), - ] - .iter() - .filter(|&&x| x) - .count() - > 1 - { - panic!("Entry must have only one data type"); - } - - if let Some(entries) = entry.entries { - let mut node = node.append(ListNode { - name: entry.name, - description: entry.description, - command: Command::None, - task_list: String::new(), - }); - create_directory(entries, &mut node, command_dir); - } else if let Some(command) = entry.command { - node.append(ListNode { - name: entry.name, - description: entry.description, - command: Command::Raw(command), - task_list: String::new(), - }); - } else if let Some(script) = entry.script { - let dir = command_dir.join(script); - if !dir.exists() { - panic!("Script {} does not exist", dir.display()); + match entry.entry_type { + EntryType::Entries(entries) => { + let mut node = node.append(ListNode { + name: entry.name, + description: entry.description, + command: Command::None, + task_list: String::new(), + }); + create_directory(entries, &mut node, command_dir, validate); + } + EntryType::Command(command) => { + node.append(ListNode { + name: entry.name, + description: entry.description, + command: Command::Raw(command), + task_list: String::new(), + }); + } + EntryType::Script(script) => { + let script = command_dir.join(script); + if !script.exists() { + panic!("Script {} does not exist", script.display()); + } + + if let Some((executable, args)) = get_shebang(&script, validate) { + node.append(ListNode { + name: entry.name, + description: entry.description, + command: Command::LocalFile { + executable, + args, + file: script, + }, + task_list: entry.task_list, + }); + } } - node.append(ListNode { - name: entry.name, - description: entry.description, - command: Command::LocalFile(dir), - task_list: entry.task_list, - }); - } else { - panic!("Entry must have data"); } } } +fn get_shebang(script_path: &Path, validate: bool) -> Option<(String, Vec)> { + let default_executable = || Some(("/bin/sh".into(), vec!["-e".into()])); + + let script = File::open(script_path).expect("Failed to open script file"); + let mut reader = BufReader::new(script); + + // Take the first 2 characters from the reader; check whether it's a shebang + let mut two_chars = [0; 2]; + if reader.read_exact(&mut two_chars).is_err() || two_chars != *b"#!" { + return default_executable(); + } + + let first_line = reader.lines().next().unwrap().unwrap(); + + let mut parts = first_line.split_whitespace(); + + let Some(executable) = parts.next() else { + return default_executable(); + }; + + let is_valid = !validate || is_executable(Path::new(executable)); + + is_valid.then(|| { + let mut args: Vec = parts.map(ToString::to_string).collect(); + args.push(script_path.to_string_lossy().to_string()); + (executable.to_string(), args) + }) +} + +fn is_executable(path: &Path) -> bool { + path.metadata() + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + impl TabList { fn get_tabs() -> Vec { let temp_dir = TempDir::new("linutil_scripts").unwrap().into_path(); diff --git a/core/src/lib.rs b/core/src/lib.rs index 3f7d36a3..22ef602b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,7 +8,12 @@ pub use inner::get_tabs; #[derive(Clone, Hash, Eq, PartialEq)] pub enum Command { Raw(String), - LocalFile(PathBuf), + LocalFile { + executable: String, + args: Vec, + // The file path is included within the arguments; don't pass this in addition + file: PathBuf, + }, None, // Directory } diff --git a/tui/src/floating_text.rs b/tui/src/floating_text.rs index f4fc3859..cde1e519 100644 --- a/tui/src/floating_text.rs +++ b/tui/src/floating_text.rs @@ -155,11 +155,10 @@ impl FloatingText { // just apply highlights directly (max_width!(get_lines(cmd)), Some(cmd.clone())) } - - Command::LocalFile(file_path) => { + Command::LocalFile { file, .. } => { // have to read from tmp dir to get cmd src - let raw = std::fs::read_to_string(file_path) - .map_err(|_| format!("File not found: {:?}", file_path)) + let raw = std::fs::read_to_string(file) + .map_err(|_| format!("File not found: {:?}", file)) .unwrap(); (max_width!(get_lines(&raw)), Some(raw)) diff --git a/tui/src/main.rs b/tui/src/main.rs index d000f719..a26a4306 100644 --- a/tui/src/main.rs +++ b/tui/src/main.rs @@ -19,10 +19,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use ratatui::{ - backend::{Backend, CrosstermBackend}, - Terminal, -}; +use ratatui::{backend::CrosstermBackend, Terminal}; use state::AppState; // Linux utility toolbox @@ -59,7 +56,10 @@ fn main() -> io::Result<()> { Ok(()) } -fn run(terminal: &mut Terminal, state: &mut AppState) -> io::Result<()> { +fn run( + terminal: &mut Terminal>, + state: &mut AppState, +) -> io::Result<()> { loop { terminal.draw(|frame| state.draw(frame)).unwrap(); // Wait for an event diff --git a/tui/src/running_command.rs b/tui/src/running_command.rs index c605b204..bc41d7c7 100644 --- a/tui/src/running_command.rs +++ b/tui/src/running_command.rs @@ -148,11 +148,19 @@ impl RunningCommand { for command in commands { match command { Command::Raw(prompt) => script.push_str(&format!("{}\n", prompt)), - Command::LocalFile(file) => { - if let Some(parent) = file.parent() { - script.push_str(&format!("cd {}\n", parent.display())); + Command::LocalFile { + executable, + args, + file, + } => { + if let Some(parent_directory) = file.parent() { + script.push_str(&format!("cd {}\n", parent_directory.display())); + } + script.push_str(&executable); + for arg in args { + script.push(' '); + script.push_str(&arg); } - script.push_str(&format!("sh {}\n", file.display())); } Command::None => panic!("Command::None was treated as a command"), } @@ -262,27 +270,25 @@ impl RunningCommand { fn handle_passthrough_key_event(&mut self, key: &KeyEvent) { let input_bytes = match key.code { KeyCode::Char(ch) => { - let mut send = vec![ch as u8]; - let upper = ch.to_ascii_uppercase(); - if key.modifiers == KeyModifiers::CONTROL { - match upper { - // https://github.com/fyne-io/terminal/blob/master/input.go - // https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b - '2' | '@' | ' ' => send = vec![0], - '3' | '[' => send = vec![27], - '4' | '\\' => send = vec![28], - '5' | ']' => send = vec![29], - '6' | '^' => send = vec![30], - '7' | '-' | '_' => send = vec![31], - char if ('A'..='_').contains(&char) => { - let ascii_val = char as u8; - let ascii_to_send = ascii_val - 64; - send = vec![ascii_to_send]; - } - _ => {} + let raw_utf8 = || ch.to_string().into_bytes(); + + match ch.to_ascii_uppercase() { + _ if key.modifiers != KeyModifiers::CONTROL => raw_utf8(), + // https://github.com/fyne-io/terminal/blob/master/input.go + // https://gist.github.com/ConnerWill/d4b6c776b509add763e17f9f113fd25b + '2' | '@' | ' ' => vec![0], + '3' | '[' => vec![27], + '4' | '\\' => vec![28], + '5' | ']' => vec![29], + '6' | '^' => vec![30], + '7' | '-' | '_' => vec![31], + c if ('A'..='_').contains(&c) => { + let ascii_val = c as u8; + let ascii_to_send = ascii_val - 64; + vec![ascii_to_send] } + _ => raw_utf8(), } - send } KeyCode::Enter => vec![b'\n'], KeyCode::Backspace => vec![0x7f], diff --git a/tui/src/state.rs b/tui/src/state.rs index f033001e..514b822b 100644 --- a/tui/src/state.rs +++ b/tui/src/state.rs @@ -357,7 +357,7 @@ impl AppState { Focus::Search => match self.filter.handle_key(key) { SearchAction::Exit => self.exit_search(), SearchAction::Update => self.update_items(), - _ => {} + SearchAction::None => {} }, Focus::TabList => match key.code { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.focus = Focus::List,