use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::io::{Error as IoError};
use std::fmt;
use regex::Regex;
#[derive(Debug)]
pub enum ScriptError {
CommandNotAllowed(String),
PathOutsideWorkingDir(PathBuf),
ProtectedPath(PathBuf),
IoError(IoError),
ParseError(String),
}
impl fmt::Display for ScriptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ScriptError::CommandNotAllowed(cmd) => write!(f, "Command not allowed: {}", cmd),
ScriptError::PathOutsideWorkingDir(path) => write!(f, "Path outside working directory: {:?}", path),
ScriptError::ProtectedPath(path) => write!(f, "Path is protected: {:?}", path),
ScriptError::IoError(err) => write!(f, "IO error: {}", err),
ScriptError::ParseError(err) => write!(f, "Parse error: {}", err),
}
}
}
impl From<IoError> for ScriptError {
fn from(error: IoError) -> Self {
ScriptError::IoError(error)
}
}
impl std::error::Error for ScriptError {}
#[derive(Debug)]
pub struct ScriptResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl From<Output> for ScriptResult {
fn from(output: Output) -> Self {
ScriptResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
}
}
}
#[derive(Debug, Clone)]
pub struct CommandProtection {
pub protected_patterns: Vec<String>,
pub override_patterns: Vec<String>,
}
#[derive(Debug, Clone)]
struct ScriptCommand {
name: String,
args: Vec<String>,
}
impl ScriptCommand {
fn new(name: String, args: Vec<String>) -> Self {
ScriptCommand { name, args }
}
}
pub struct BurgerFlipper {
allowed_commands: HashSet<String>,
working_dir: PathBuf,
global_protected_patterns: Vec<String>,
command_protections: HashMap<String, CommandProtection>,
fs_commands: HashSet<String>,
}
impl BurgerFlipper {
pub fn new<P: AsRef<Path>>(
allowed_commands: Vec<String>,
working_dir: P,
global_protected_patterns: Vec<String>,
) -> Self {
let working_dir = working_dir.as_ref().to_path_buf();
let fs_commands = vec![
"ls".to_string(), "cat".to_string(), "cp".to_string(),
"mv".to_string(), "rm".to_string(), "mkdir".to_string(),
"rmdir".to_string(), "touch".to_string()
];
BurgerFlipper {
allowed_commands: allowed_commands.into_iter().collect(),
working_dir,
global_protected_patterns,
command_protections: HashMap::new(),
fs_commands: fs_commands.into_iter().collect(),
}
}
pub fn add_command_protection(
&mut self,
command: String,
protected_patterns: Vec<String>,
override_patterns: Vec<String>,
) {
self.command_protections.insert(
command,
CommandProtection {
protected_patterns,
override_patterns,
},
);
}
pub fn add_fs_command(&mut self, command: String) {
self.fs_commands.insert(command);
}
pub fn execute(&self, script: &str) -> Result<ScriptResult, ScriptError> {
let commands = self.parse_script(script)?;
if commands.is_empty() {
return Err(ScriptError::ParseError("Empty script".to_string()));
}
if commands.len() == 1 {
return self.execute_command(&commands[0]);
}
if commands.len() == 2 && commands[0].name == "fs" && commands[0].args.is_empty() {
if self.fs_commands.contains(&commands[1].name) {
return self.execute_command(&commands[1]);
} else {
return Err(ScriptError::CommandNotAllowed(format!("fs {}", commands[1].name)));
}
}
Err(ScriptError::ParseError("Complex scripts not supported".to_string()))
}
fn execute_command(&self, cmd: &ScriptCommand) -> Result<ScriptResult, ScriptError> {
if !self.allowed_commands.contains(&cmd.name) {
return Err(ScriptError::CommandNotAllowed(cmd.name.clone()));
}
self.validate_paths(&cmd.name, &cmd.args)?;
let output = Command::new(&cmd.name)
.args(&cmd.args)
.current_dir(&self.working_dir)
.output()?;
Ok(output.into())
}
fn parse_script(&self, script: &str) -> Result<Vec<ScriptCommand>, ScriptError> {
let tokens = self.tokenize_script(script)?;
if tokens.is_empty() {
return Ok(vec![]);
}
if tokens.len() >= 2 && tokens[0] == "fs" {
if tokens.len() == 1 {
return Err(ScriptError::ParseError("Incomplete fs command".to_string()));
}
let fs_cmd = ScriptCommand::new("fs".to_string(), vec![]);
let cmd_name = tokens[1].to_string();
let cmd_args = if tokens.len() > 2 {
tokens[2..].to_vec()
} else {
vec![]
};
let sub_cmd = ScriptCommand::new(cmd_name, cmd_args);
return Ok(vec![fs_cmd, sub_cmd]);
}
let mut commands = Vec::new();
let mut i = 0;
while i < tokens.len() {
let start = i;
while i < tokens.len() && tokens[i] != ";" {
i += 1;
}
if i > start {
let cmd_name = tokens[start].to_string();
let cmd_args = if i > start + 1 {
tokens[start+1..i].to_vec()
} else {
vec![]
};
commands.push(ScriptCommand::new(cmd_name, cmd_args));
}
if i < tokens.len() {
i += 1;
}
}
Ok(commands)
}
fn tokenize_script(&self, script: &str) -> Result<Vec<String>, ScriptError> {
let mut tokens = Vec::new();
let mut current_token = String::new();
let mut in_single_quotes = false;
let mut in_double_quotes = false;
let mut escape_next = false;
for c in script.chars() {
if escape_next {
current_token.push(c);
escape_next = false;
} else if c == '\\' {
escape_next = true;
} else if c == '\'' && !in_double_quotes {
in_single_quotes = !in_single_quotes;
} else if c == '"' && !in_single_quotes {
in_double_quotes = !in_double_quotes;
} else if (c.is_whitespace() || c == ';') && !in_single_quotes && !in_double_quotes {
if !current_token.is_empty() {
tokens.push(current_token);
current_token = String::new();
}
if c == ';' {
tokens.push(";".to_string());
}
} else {
current_token.push(c);
}
}
if in_single_quotes || in_double_quotes {
return Err(ScriptError::ParseError("Unterminated quotes".to_string()));
}
if escape_next {
return Err(ScriptError::ParseError("Unterminated escape sequence".to_string()));
}
if !current_token.is_empty() {
tokens.push(current_token);
}
Ok(tokens)
}
fn validate_paths(&self, cmd: &str, args: &[String]) -> Result<(), ScriptError> {
if !self.fs_commands.contains(cmd) {
return Ok(());
}
for arg in args {
if arg.starts_with('-') {
continue;
}
let path = PathBuf::from(arg);
let abs_path = if path.is_absolute() {
path.clone()
} else {
self.working_dir.join(&path)
};
if !is_path_within(&abs_path, &self.working_dir)? {
return Err(ScriptError::PathOutsideWorkingDir(abs_path));
}
if self.is_path_protected(cmd, &abs_path)? {
return Err(ScriptError::ProtectedPath(abs_path));
}
}
Ok(())
}
fn is_path_protected(&self, cmd: &str, path: &Path) -> Result<bool, ScriptError> {
let path_str = path.to_string_lossy().to_string();
if let Some(protection) = self.command_protections.get(cmd) {
for pattern in &protection.override_patterns {
if path_matches_pattern(&path_str, pattern)? {
return Ok(false);
}
}
for pattern in &protection.protected_patterns {
if path_matches_pattern(&path_str, pattern)? {
return Ok(true);
}
}
}
for pattern in &self.global_protected_patterns {
if path_matches_pattern(&path_str, pattern)? {
return Ok(true);
}
}
Ok(false)
}
}
fn is_path_within(path: &Path, base: &Path) -> Result<bool, ScriptError> {
let path_canon = match path.canonicalize() {
Ok(p) => p,
Err(_) => {
match path.parent() {
Some(parent) => {
match parent.canonicalize() {
Ok(p) => {
if let Some(filename) = path.file_name() {
p.join(filename)
} else {
path.to_path_buf()
}
},
Err(_) => path.to_path_buf()
}
},
None => path.to_path_buf()
}
}
};
let base_canon = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
Ok(path_canon.starts_with(&base_canon))
}
fn path_matches_pattern(path: &str, pattern: &str) -> Result<bool, ScriptError> {
let regex_pattern = glob_to_regex(pattern)?;
let regex = Regex::new(®ex_pattern)
.map_err(|e| ScriptError::ParseError(format!("Invalid regex: {}", e)))?;
Ok(regex.is_match(path))
}
fn glob_to_regex(pattern: &str) -> Result<String, ScriptError> {
let mut regex = "^".to_string();
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next();
regex.push_str(".*");
} else {
regex.push_str("[^/]*");
}
},
'?' => regex.push('.'),
'[' => {
regex.push('[');
let mut in_bracket = true;
while let Some(&next_c) = chars.peek() {
chars.next();
if next_c == ']' {
regex.push(']');
in_bracket = false;
break;
} else {
regex.push(next_c);
}
}
if in_bracket {
return Err(ScriptError::ParseError("Unterminated bracket expression".to_string()));
}
},
'{' => {
regex.push('(');
let mut in_brace = true;
while let Some(&next_c) = chars.peek() {
chars.next();
if next_c == '}' {
regex.push(')');
in_brace = false;
break;
} else if next_c == ',' {
regex.push('|');
} else {
regex.push(next_c);
}
}
if in_brace {
return Err(ScriptError::ParseError("Unterminated brace expression".to_string()));
}
},
'.' | '+' | '(' | ')' | '^' | '$' | '\\' | '|' => {
regex.push('\\');
regex.push(c);
},
_ => regex.push(c),
}
}
regex.push('$');
Ok(regex)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
#[test]
fn test_basic_command_execution() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let executor = BurgerFlipper::new(
vec!["echo".to_string()],
temp_path,
vec![],
);
let result = executor.execute("echo cheeseburger").unwrap();
assert_eq!(result.stdout.trim(), "cheeseburger");
assert_eq!(result.exit_code, 0);
}
#[test]
fn test_command_not_allowed() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let executor = BurgerFlipper::new(
vec!["echo".to_string()],
temp_path,
vec![],
);
let result = executor.execute("ls");
assert!(matches!(result, Err(ScriptError::CommandNotAllowed(_))));
}
#[test]
fn test_protected_path() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let secret_path = temp_path.join("secret_file.txt");
fs::write(&secret_path, "secret content").unwrap();
let mut executor = BurgerFlipper::new(
vec!["cat".to_string()],
temp_path,
vec!["**/secret*".to_string()],
);
executor.add_fs_command("cat".to_string());
let result = executor.execute(&format!("cat {}", secret_path.display()));
assert!(matches!(result, Err(ScriptError::ProtectedPath(_))));
}
#[test]
fn test_command_specific_override() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let secret_path = temp_path.join("secret_but_allowed.txt");
fs::write(&secret_path, "viewable content").unwrap();
let mut executor = BurgerFlipper::new(
vec!["cat".to_string()],
temp_path,
vec!["**/secret*".to_string()],
);
executor.add_fs_command("cat".to_string());
executor.add_command_protection(
"cat".to_string(),
vec![],
vec!["**/secret_but_allowed.txt".to_string()]
);
let result = executor.execute(&format!("cat {}", secret_path.display())).unwrap();
assert_eq!(result.stdout.trim(), "viewable content");
}
#[test]
fn test_fs_namespace() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let executor = BurgerFlipper::new(
vec!["ls".to_string()],
temp_path,
vec![],
);
let result = executor.execute("fs ls");
assert!(result.is_ok());
}
}