diff --git a/README.md b/README.md index 2cded86..a0a973a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,3 @@ Once you have that installed, just run cargo build --release ``` and you will find an executable in `target/release`. - -### Unit tests - -In order for the unit tests to pass, you will need `python3`. diff --git a/src/command.rs b/src/command.rs deleted file mode 100644 index 5636d99..0000000 --- a/src/command.rs +++ /dev/null @@ -1,200 +0,0 @@ -use crate::log; -use crate::logger::{LogLevel, Logger}; -use std::error::Error; -use std::fmt::{Debug, Display, Formatter}; -use std::io; -use std::iter::once; -use std::process::{Command, ExitStatus, Output}; - -pub trait LogRunnable { - fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError>; - fn collect_output(&mut self) -> Result>; - fn collect_full_output(&mut self) -> Result>; -} - -impl LogRunnable for Command { - fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError> { - run(self, logger).map_err(|error| CommandSpecificError { - command: self, - error, - }) - } - - fn collect_output(&mut self) -> Result> { - collect_output(self, None).map_err(|error| CommandSpecificError { - command: self, - error, - }) - } - - fn collect_full_output(&mut self) -> Result> { - collect_full_output(self).map_err(|error| CommandSpecificError { - command: self, - error, - }) - } -} - -fn run(command: &mut Command, logger: &Logger) -> Result<(), ExecutionError> { - match logger.level { - LogLevel::Debug | LogLevel::Info => { - let status = command.status()?; - if !status.success() { - Err(status)?; - } - } - LogLevel::Error => { - collect_output(command, Some(logger))?; - } - } - Ok(()) -} - -fn collect_output( - command: &mut Command, - logger: Option<&Logger>, -) -> Result { - let output = collect_full_output(command)?; - if !output.status.success() { - if let Some(logger) = logger { - log!(logger, error, "{}", String::from_utf8_lossy(&output.stdout)); - log!(logger, error, "{}", String::from_utf8_lossy(&output.stderr)); - } - Err(output.status)?; - } - Ok(output) -} - -fn collect_full_output(command: &mut Command) -> Result { - Ok(command.output()?) -} - -#[derive(Debug)] -pub struct CommandSpecificError<'a, E> { - pub command: &'a Command, - pub error: E, -} - -impl Display for CommandSpecificError<'_, E> -where - E: Display, -{ - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Failed to execute command '{}': {}", - command_to_string(self.command), - self.error - ) - } -} - -fn command_to_string(command: &Command) -> String { - once(command.get_program().to_string_lossy().to_string()) - .chain(command.get_args().map(|arg| { - let arg_str = arg.to_string_lossy(); - if arg_str.contains(' ') { - format!("\"{arg_str}\"") - } else { - arg_str.to_string() - } - })) - .collect::>() - .join(" ") -} - -impl Error for CommandSpecificError<'_, E> where E: Debug + Display {} - -#[derive(Debug)] -pub struct StartError(io::Error); - -impl From for StartError { - fn from(value: io::Error) -> Self { - StartError(value) - } -} - -impl Display for StartError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "Failed to start command: {}", self.0) - } -} - -impl Error for StartError {} - -#[derive(Debug)] -pub enum ExecutionError { - StartError(StartError), - BadExitStatus(ExitStatus), -} - -impl From for ExecutionError { - fn from(value: io::Error) -> Self { - Self::StartError(StartError(value)) - } -} - -impl From for ExecutionError { - fn from(value: StartError) -> Self { - Self::StartError(value) - } -} - -impl From for ExecutionError { - fn from(value: ExitStatus) -> Self { - Self::BadExitStatus(value) - } -} - -impl Display for ExecutionError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ExecutionError::StartError(e) => Display::fmt(e, f), - ExecutionError::BadExitStatus(status) => write!(f, "Command failed with {}", status), - } - } -} - -impl Error for ExecutionError {} - -#[cfg(test)] -mod test { - use crate::command::{CommandSpecificError, ExecutionError, LogRunnable}; - use crate::logger::Logger; - use std::path::PathBuf; - use std::process::Command; - - #[test] - fn test_unknown_command() { - let mut command = Command::new("python7"); - let Err( - e @ CommandSpecificError { - error: ExecutionError::StartError(_), - .. - }, - ) = command - .args([PathBuf::from("test-ressources/python/exit_1.py")]) - .run(&Logger::default()) - else { - panic!("command shouldn't exist"); - }; - assert_eq!(e.to_string(), "Failed to execute command 'python7': Failed to start command: No such file or directory (os error 2)"); - } - - #[test] - fn test_error() { - let mut command = Command::new("python3"); - let Err( - e @ CommandSpecificError { - error: ExecutionError::BadExitStatus(_), - .. - }, - ) = command - .arg("test-ressources/python/exit_1.py") - .run(&Logger::default()) - else { - panic!("command should return exit-code 1") - }; - assert_eq!(e.to_string(), "Failed to execute command 'python3 test-ressources/python/exit_1.py': Command failed with exit status: 1"); - } -} diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..6328bfd --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,96 @@ +use crate::shell_interface::{ + build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand, + ShellInterface, StartError, +}; +use std::env::VarError; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; +use std::{env, io}; + +pub trait Environment { + fn args_os(&self) -> Vec; + fn var_os(&self, key: K) -> Option + where + K: AsRef; + fn var(&self, key: K) -> Result + where + K: AsRef, + { + self + .var_os(key) + .ok_or(VarError::NotPresent) + .and_then(|s| s.into_string().map_err(VarError::NotUnicode)) + } + fn set_var(&mut self, key: K, value: V) + where + K: AsRef, + V: AsRef; + fn get_home_directory(&self) -> Option; + fn read_line(&mut self) -> Result; +} + +#[derive(Debug, Default)] +pub struct Prod; + +impl Environment for Prod { + fn args_os(&self) -> Vec { + env::args_os().collect() + } + + fn var_os(&self, key: K) -> Option + where + K: AsRef + { + env::var_os(key) + } + + fn var(&self, key: K) -> Result + where + K: AsRef, + { + env::var(key) + } + + fn set_var(&mut self, key: K, value: V) + where + K: AsRef, + V: AsRef, + { + env::set_var(key, value); + } + + fn get_home_directory(&self) -> Option { + homedir::my_home().ok().flatten() + } + + fn read_line(&mut self) -> Result { + let mut buffer = String::new(); + io::stdin().read_line(&mut buffer)?; + Ok(buffer.trim().to_string()) + } +} + +impl ShellInterface for Prod { + fn run_command(&mut self, command: ShellCommand) -> CommandResult { + CommandResult { + result: build_command_from_shell_command(&command) + .status() + .map(ExitStatus::from) + .map_err(StartError::from), + command, + } + } + + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult { + CommandResult { + result: build_command_from_shell_command(&command) + .output() + .map(CommandOutput::from) + .map_err(StartError::from), + command, + } + } +} diff --git a/src/integration_test.rs b/src/integration_test.rs new file mode 100644 index 0000000..46358bf --- /dev/null +++ b/src/integration_test.rs @@ -0,0 +1,90 @@ +use crate::environment::Environment; +use crate::shell_interface::{ + CommandOutput, CommandResult, ExitStatus, ShellCommand, ShellInterface, StartError, +}; +use std::collections::{HashMap, VecDeque}; +use std::ffi::{OsStr, OsString}; +use std::io::Error; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct TestEnvironment { + /// passed command line arguments + args_os: Vec, + /// set environment variables - we assume a pure environment by default + env_vars: HashMap, + /// home directory, relative to the target/test folder + home_dir: PathBuf, + /// pending lines of std input + stdin: VecDeque, + /// whether an ssh agent has been started successfully + ssh_agent_started: bool, + // TODO ssh servers and local server +} + +#[derive(Debug)] +pub struct SshServer { + pub name: String, + pub home_dir: FsEntry, +} + +#[derive(Debug)] +pub struct FsEntry { + pub name: OsString, + pub kind: FsEntryKind, +} + +#[derive(Debug)] +pub enum FsEntryKind { + Directory(Dir), + File { + contents: String, + } +} + +#[derive(Debug)] +pub struct Dir { + pub contents: Vec, +} + +impl Environment for TestEnvironment { + fn args_os(&self) -> Vec { + self.args_os.clone() + } + + fn var_os(&self, key: K) -> Option + where + K: AsRef + { + self.env_vars.get(key.as_ref()).map(|s| s.into()) + } + + fn set_var(&mut self, key: K, value: V) + where + K: AsRef, + V: AsRef, + { + self.env_vars.insert(key.as_ref().into(), value.as_ref().into()); + } + + fn get_home_directory(&self) -> Option { + PathBuf::from("target/integration_test").join(&self.home_dir).into() + } + + fn read_line(&mut self) -> Result { + self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared")) + } +} + +impl ShellInterface for TestEnvironment { + fn run_command(&mut self, command: ShellCommand) -> CommandResult { + todo!() + } + + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult { + todo!() + } +} diff --git a/src/main.rs b/src/main.rs index cc7d05d..53dc2d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,22 @@ mod action; -mod command; +mod environment; mod file; +#[cfg(test)] +mod integration_test; mod logger; -mod os_string_builder; mod os_str_extension; +mod os_string_builder; mod server; +mod shell_interface; use crate::action::{Action, FileAction, ServerActions}; -use crate::command::{CommandSpecificError, ExecutionError, LogRunnable}; +use crate::environment::{Environment, Prod}; use crate::file::{FileMatcher, FileNameInfo}; use crate::logger::{LogLevel, Logger}; use crate::os_str_extension::OsStrExtension; use crate::os_string_builder::ReplaceWithOsStr; use crate::server::{RelativeLocalPathAnker, ServerAddress}; +use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface}; use clap::{Parser, Subcommand, ValueEnum}; use lazy_regex::{lazy_regex, Lazy, Regex}; use server::{Server, ServerReference}; @@ -22,15 +26,12 @@ use std::hash::Hash; use std::io::Write; use std::iter::once; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::{env, fs, io}; const SERVERS_ENV_VAR: &str = "MSSH_SERVERS"; const EDITOR_ENV_VAR: &str = "MSSH_EDITOR"; const FILE_PLACEHOLDER: &str = ""; -type ShellCmd = std::process::Command; - /// Uploads a file or executes a command on multiple configured servers /// /// Servers must either be configured via environment variable or denote their server directory with @@ -41,13 +42,13 @@ type ShellCmd = std::process::Command; /// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers. #[derive(Parser, Debug)] #[command(version, about, long_about)] -struct Args { +pub struct Args { /// The action to perform #[command(subcommand)] command: Command, /// The ssh names and optionally home directories of the servers to perform the action on - #[arg(num_args = 0.., value_parser = ServerReference::from_str)] - servers: Vec, + #[arg(num_args = 0..)] + servers: Vec, /// How verbose logging output should be #[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])] log_level: LogLevel, @@ -71,7 +72,7 @@ enum Command { /// When this option is set, the file path must be absolute, or relative to the server directory. /// The upload-directory has no influence on where the file will be taken from. #[arg(short = 'S', long)] - file_server: Option, + file_server: Option, /// How to handle older versions of the file #[arg(short = 'a', long, default_value = "delete", default_missing_value = "archive", num_args = 0..=1)] old_version_policy: OldVersionPolicy, @@ -137,613 +138,663 @@ enum OldVersionPolicy { Delete, } -#[macro_export] -macro_rules! input { - ($prompt: tt) => {{ - print!($prompt); - io::stdout().flush().expect("failed to flush stdout"); - let mut buf = String::new(); - io::stdin() - .read_line(&mut buf) - .expect("failed to read stdin"); - buf.trim().to_string() - }}; - () => { - input!() - }; +#[derive(Debug, Default)] +pub struct Application { + pub environment: E, } -fn main() -> Result<(), String> { - let args = Args::parse(); +impl Application +where + E: Environment + ShellInterface, +{ + pub fn run(&mut self) -> Result<(), String> { + let args = Args::try_parse_from(self.environment.args_os()).map_err(|e| e.to_string())?; + self.run_with_args(args) + } - let logger = Logger { - //all the below options are conflicting with each other so an if else is fine - level: if args.quiet { - LogLevel::Error - } else if args.info { - LogLevel::Debug - } else { - args.log_level - }, - }; - - let mut configured_servers = LazyCell::new(parse_server_configuration_from_env); - let servers = args - .servers - .iter() - .map(|server_reference| { - let server_identifier = server_reference.get_identifier(); - server_reference - .clone() - .try_resolve_lazy(&mut configured_servers) - .map_err(|msg| format!("Can't resolve server directory for '{server_identifier}': {msg}")) - .and_then(|opt_server| { - opt_server.ok_or(format!( - "no server directory has been configured for server '{server_identifier}'" - )) - }) - }) - .collect::, _>>()?; - - match args.command { - Command::Upload { - mut files, - file_server, - old_version_policy, - mut upload_directory, - no_confirm, - file_name, - pure, - exclude, - } => { - require_non_empty_servers(&servers)?; - require_non_empty(&files, "files to upload")?; - start_ssh_agent(&logger)?; - - //resolve file server - let file_server = match file_server { - Some(server_reference) => { - let file_server_identifier = server_reference.get_identifier().to_string(); - let server = server_reference.try_resolve_lazy(&mut configured_servers) - .map_err(|e| format!("Can't resolve server directory for file-server '{file_server_identifier}': {e}"))? - .ok_or_else(|| format!("no server directory has been configured for file-server '{file_server_identifier}'"))?; - Some(server) - } - None => None, + pub fn run_with_args(&mut self, args: Args) -> Result<(), String> { + macro_rules! env { + () => { + &mut self.environment }; + } - //make sure files exist - match &file_server { - Some(file_server) => match &file_server.address { - ServerAddress::Ssh { ssh_address } => { - //canonicalize remote files -> also makes sure they exist - files = files - .iter() - .map(|file| { - let output = ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("realpath -e ") + file_server.server_directory_path.join(file)) - .collect_full_output() + let logger = Logger { + //all the below options are conflicting with each other so an if else is fine + level: if args.quiet { + LogLevel::Error + } else if args.info { + LogLevel::Debug + } else { + args.log_level + }, + }; + + let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env()); + let servers = args + .servers + .into_iter() + .map(|ref_str| { + ServerReference::from_str(&ref_str, || self.get_home_directory()) + .map_err(|e| format!("Invalid server reference '{ref_str}': {e}")) + }) + .collect::, _>>()? + .into_iter() + .map(|server_reference| { + let server_identifier = server_reference.get_identifier(); + server_reference + .clone() + .try_resolve_lazy(&mut configured_servers) + .map_err(|msg| format!("Can't resolve server directory for '{server_identifier}': {msg}")) + .and_then(|opt_server| { + opt_server.ok_or(format!( + "no server directory has been configured for server '{server_identifier}'" + )) + }) + }) + .collect::, _>>()?; + + match args.command { + Command::Upload { + mut files, + file_server, + old_version_policy, + mut upload_directory, + no_confirm, + file_name, + pure, + exclude, + } => { + Self::require_non_empty_servers(&servers)?; + Self::require_non_empty(&files, "files to upload")?; + + //resolve file server + let file_server = match file_server { + Some(ref_str) => { + let server_reference = + ServerReference::from_str(&ref_str, || self.get_home_directory()) + .map_err(|e| format!("Invalid file-server reference '{ref_str}': {e}"))?; + let file_server_identifier = server_reference.get_identifier().to_string(); + let server = server_reference.try_resolve_lazy(&mut configured_servers) + .map_err(|e| format!("Can't resolve server directory for file-server '{file_server_identifier}': {e}"))? + .ok_or_else(|| format!("no server directory has been configured for file-server '{file_server_identifier}'"))?; + Some(server) + } + None => None, + }; + + self.start_ssh_agent(&logger)?; + + //make sure files exist + match &file_server { + Some(file_server) => match &file_server.address { + ServerAddress::Ssh { ssh_address } => { + //canonicalize remote files -> also makes sure they exist + files = files + .iter() + .map(|file| { + let output = ShellCommand::Ssh { + address: ssh_address.to_string(), + server_command: ServerCommand::Realpath { + path: file_server.server_directory_path.join(file), + }, + } + .in_env(env!()) + .output() + .into_result() .map_err(|e| format!("Failed to canonicalize files: {e}"))?; - if !output.status.success() { - Err(format!( - "Path doesn't match any files on file-server: {}", - file.to_string_lossy() - ))?; - } - - let denoted_files = osstring_from_ssh_output(output.stdout) - .split(b'\n') //split at line breaks - .into_iter() - .filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines - .map(PathBuf::from) - .collect::>(); - - Ok(denoted_files) - }) - .collect::, String>>()? - .into_iter() - .flatten() - .collect(); - } - ServerAddress::Localhost => files - .iter() - .map(|file| file_server.server_directory_path.join(file)) - .try_for_each(check_local_file_exists)?, - }, - None => files.iter().try_for_each(check_local_file_exists)?, - } - - let file_details = files - .into_iter() - .map(|file| { - FileNameInfo::try_from(file.clone()) - .map(|info| (file.clone(), info)) - .map_err(|e| format!("Bad file '{}': {e}", file.to_string_lossy())) - }) - .collect::, _>>()? - .into_iter() - .filter(|(_, info)| { - !exclude - .iter() - .any(|exclude| info.to_full_file_name().starts_with(exclude)) - }) - .collect::>(); - - log!(logger, debug, "Files to upload: "); - for (file, _) in &file_details { - log!(logger, debug, "- {}", file.to_string_lossy()); - } - - //create overview of what has to be done on each server - let actions = servers - .iter() - .map(|server| { - //on local server canonicalize upload_directory - if let ServerAddress::Localhost = &server.address { - //create upload directory if it doesn't exist - fs::create_dir_all(&upload_directory) - .map_err(|e| format!("Failed to create upload-directory: {e}"))?; - - upload_directory = fs::canonicalize(&upload_directory) - .map_err(|e| format!("failed to resolve upload-directory: {e}"))?; - } - let working_directory = server.server_directory_path.join(&upload_directory); - Ok(ServerActions { - server, - actions: { - let present_file_names: Vec = match &server.address { - ServerAddress::Ssh { ssh_address } => osstring_from_ssh_output( - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("ls ") + &working_directory) - .collect_output() - .map_err(|e| { - format!( - "Failed to query present files on server {}: {e}", - server.get_name() - ) - })? - .stdout, - ) - .split(b'\n') - .into_iter() - .map(OsString::from) - .collect(), - ServerAddress::Localhost => fs::read_dir(&working_directory) - .map_err(|e| format!("Failed to get files in working directory: {e}"))? - .map(|entry| entry.map_err(|e| format!("Failed to access directory entry: {e}"))) - .collect::, _>>()? - .into_iter() - .filter_map(|entry| { - if entry.path().is_file() { - Some(entry.file_name()) - } else { - None - } - }) - .collect(), - }; - - file_details - .iter() - .flat_map(|(file, file_name_info)| { - let mut file_matcher = FileMatcher::from( - file_name - .as_ref() - .map(OsString::from) - .unwrap_or(file_name_info.name.to_os_string()), - ); - if let Some(extension) = file_name_info.extension.as_ref() { - file_matcher = file_matcher.and_extension(extension); + if !output.status.success { + Err(format!( + "Path doesn't match any files on file-server: {}", + file.to_string_lossy() + ))?; } - let file_name = file_name_info.to_full_file_name(); + let denoted_files = output + .stdout + .split(b'\n') //split at line breaks + .into_iter() + .filter(|file_name| !file_name.is_empty()) //needed since realpath sometimes gives us empty lines + .map(PathBuf::from) + .collect::>(); - let add_action = FileAction::new(file, Action::Add).expect("path points to file"); - - if pure && present_file_names.iter().any(|file| *file == file_name) { - log!( - logger, - debug, - "file is already present on {}: {}", - server.get_name(), - file_name.to_string_lossy() - ); - return vec![]; //ignore that file, since it is already present - } - - match old_version_policy { - OldVersionPolicy::Ignore => { - if !present_file_names.iter().any(|file| *file == file_name) { - vec![add_action] //file doesn't exist yet - } else { - vec![FileAction::new(file, Action::Replace) - .expect("path points to file")] - } - } - OldVersionPolicy::Archive => present_file_names - .iter() - .filter(|file| file_matcher.matches(file)) - .map(|file| { - FileAction::new( - file, - Action::rename( - osf!(file) - + file - .to_string_lossy() - .chars() - .last() - .unwrap_or('1') - .to_string(), - ), - ) - .expect("path points to file") - }) - .chain(once(add_action)) - .collect(), - OldVersionPolicy::Delete => { - let mut actions = present_file_names - .iter() - .filter(|file| file_matcher.matches(file)) - .map(|present_file| { - //special case -> file has the same name as current file, then we just need to replace it - if *present_file == file_name { - FileAction::new(file, Action::Replace).expect("path points to file") - } else { - FileAction::new(present_file, Action::Delete).expect("path points to file") - } - }) - .collect::>(); - if !actions.iter().any(|action| action.kind == Action::Replace) { - actions.push(add_action); - } - actions - } - } + Ok(denoted_files) }) - .collect() - }, - working_directory, - }) - }) - .collect::, String>>()? - .into_iter() - .filter(|server_actions| !server_actions.actions.is_empty()) - .collect::>(); - - if actions.is_empty() { - log!(logger, "Nothing to be done, everything is up to date"); - return Ok(()); - } - - log!(logger, "The following actions will be performed: "); - for server_actions in &actions { - log!(logger, "{server_actions}"); - log!(logger, debug, "Detailed file actions: "); - for file_action in &server_actions.actions { - log!(logger, debug, "{file_action:?}"); - } - } - - if !no_confirm { - match input!("Continue? [Y|n] ").to_lowercase().as_str() { - "n" | "no" => { - log!(logger, "Aborting..."); - return Ok(()); - } - _ => {} - } - } - - for server_actions in actions { - let server = server_actions.server; - log!(logger, "Performing actions on {}...", server.get_name()); - for file_action in server_actions.actions { - match file_action.kind { - Action::Add | Action::Replace => { - let scp_source = match &file_server { - Some(file_server) => { - osf!(match &file_server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + file_server.server_directory_path.join(&file_action.file) - } - None => osf!(&file_action.file), - }; - let scp_target = osf!(match &server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + &server_actions.working_directory; - ShellCmd::new("scp") - .arg(scp_source) - .arg(scp_target) - .run(&logger) - .map_err(|e| format!("upload failure: {e}"))?; + .collect::, String>>()? + .into_iter() + .flatten() + .collect(); } - Action::Delete => match &server.address { - ServerAddress::Ssh { ssh_address } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("rm ") + server_actions.working_directory.join(&file_action.file)) - .run(&logger) - .map_err(|e| format!("failed to delete old version: {e}"))?; - } - ServerAddress::Localhost => { - fs::remove_file(server_actions.working_directory.join(&file_action.file)) - .map_err(|e| format!("failed to delete old version: {e}"))?; - } - }, - Action::Rename { new_name } => match &server.address { - ServerAddress::Ssh { ssh_address } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg( - osf!("mv ") - + server_actions.working_directory.join(&file_action.file) - + " " - + server_actions.working_directory.join(&new_name), - ) - .run(&logger) - .map_err(|e| format!("failed to rename: {e}"))?; - } - ServerAddress::Localhost => { - let dir = &server_actions.working_directory; - fs::rename(dir.join(&file_action.file), dir.join(&new_name)) - .map_err(|e| format!("failed to rename: {e}"))?; - } - }, - } + ServerAddress::Localhost => files + .iter() + .map(|file| file_server.server_directory_path.join(file)) + .try_for_each(Self::check_local_file_exists)?, + }, + None => files.iter().try_for_each(Self::check_local_file_exists)?, } - } - log!(logger, "Done!"); - } - Command::Command { command } => { - start_ssh_agent(&logger)?; - require_non_empty_servers(&servers)?; - for server in servers { - log!(logger, "Running command on '{}'...", server.get_name()); - match &server.address { - ServerAddress::Ssh { ssh_address } => { - ShellCmd::new("ssh") - .arg(ssh_address) - .arg(osf!("cd ") + server.server_directory_path + "; " + &command) - .run(&logger) - .map_err(|e| format!("{e}"))?; - } - ServerAddress::Localhost => { - let mut command_args = - shell_words::split(&command).map_err(|e| format!("failed to parse command: {e}"))?; - ShellCmd::new(command_args.remove(0)) - .args(&command_args) - .current_dir(&server.server_directory_path) - .run(&logger) - .map_err(|e| format!("{e}"))?; - } - } - } - log!(logger, "Done!"); - } - Command::Editor { - file, - editor, - download_directory, - override_existing, - } => { - //determine download directory - let download_directory = match download_directory { - Some(download_directory) => download_directory, - None => { - let home_dir = - get_home_directory().map_err(|e| format!("Can't determine download directory: {e}"))?; - home_dir.join("Downloads") - } - }; - - //get editor - let editor = editor.ok_or(()).or_else(|_| env::var(EDITOR_ENV_VAR).map_err(|e| format!("You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e}")))?; - - fs::create_dir_all(&download_directory) - .map_err(|e| format!("failed to create working directory: {e}"))?; - - //make sure file doesn't exist in working directory yet, or it will be overridden - let file_name = file - .file_name() - .ok_or("can only edit files, not directories")?; - 'duplicate_check: { - if !override_existing - && fs::read_dir(&download_directory) - .map_err(|e| format!("failed to open working directory: {e}"))? - .collect::, _>>() - .map_err(|e| format!("error while querying working directory contents: {e}"))? - .iter() - .any(|entry| entry.file_name() == file_name) - { - let duplication_notification = format!( - "A file with the name {} already exists in {}", - file_name.to_string_lossy(), - download_directory.to_string_lossy() - ); - - if !args.quiet { - match input!("{duplication_notification}. Do you want to replace it? [N|y] ") - .to_lowercase() - .as_str() - { - "y" | "yes" => break 'duplicate_check, - _ => {} - } - } - - return Err(format!( - "{duplication_notification}. You can override it with --override or -f" - )); - } - } - - require_non_empty_servers(&servers)?; - start_ssh_agent(&logger)?; - - for server in servers { - log!(logger, "Getting file from {}...", server.get_name()); - let file_source = osf!(match &server.address { - ServerAddress::Ssh { ssh_address } => format!("{ssh_address}:"), - ServerAddress::Localhost => "".to_string(), - }) + server.server_directory_path.join(&file); - ShellCmd::new("scp") - .arg(&file_source) - .arg(&download_directory) - .run(&logger) - .map_err(|e| format!("download failure: {e}"))?; - - //open file in editor - let mut editor_command_args = shell_words::split(&editor) - .map_err(|e| format!("failed to parse editor command: {e}"))? + let file_details = files .into_iter() - .map(|part| { - part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name)) + .map(|file| { + FileNameInfo::try_from(file.clone()) + .map(|info| (file.clone(), info)) + .map_err(|e| format!("Bad file '{}': {e}", file.to_string_lossy())) + }) + .collect::, _>>()? + .into_iter() + .filter(|(_, info)| { + !exclude + .iter() + .any(|exclude| info.to_full_file_name().starts_with(exclude)) }) .collect::>(); - let command = editor_command_args.remove(0); - ShellCmd::new(command) - .args(editor_command_args) - .run(&logger) - .map_err(|e| format!("failed to open file in editor: {e}"))?; + log!(logger, debug, "Files to upload: "); + for (file, _) in &file_details { + log!(logger, debug, "- {}", file.to_string_lossy()); + } - //upload file again - ShellCmd::new("scp") - .arg(download_directory.join(file_name)) - .arg(&file_source) - .run(&logger) - .map_err(|e| format!("failed to re-upload file: {e}"))?; + //create overview of what has to be done on each server + let actions = servers + .iter() + .map(|server| { + //on local server canonicalize upload_directory + if let ServerAddress::Localhost = &server.address { + //create upload directory if it doesn't exist + fs::create_dir_all(&upload_directory) + .map_err(|e| format!("Failed to create upload-directory: {e}"))?; + + upload_directory = fs::canonicalize(&upload_directory) + .map_err(|e| format!("failed to resolve upload-directory: {e}"))?; + } + let working_directory = server.server_directory_path.join(&upload_directory); + Ok(ServerActions { + server, + actions: { + let present_file_names: Vec = match &server.address { + ServerAddress::Ssh { ssh_address } => ShellCommand::Ssh { + address: ssh_address.to_string(), + server_command: ServerCommand::Ls { + dir: working_directory.clone(), + }, + } + .in_env(env!()) + .output() + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| { + format!( + "Failed to query present files on server {}: {e}", + server.get_name() + ) + })? + .stdout + .split(b'\n') + .into_iter() + .map(OsString::from) + .collect(), + ServerAddress::Localhost => fs::read_dir(&working_directory) + .map_err(|e| format!("Failed to get files in working directory: {e}"))? + .map(|entry| { + entry.map_err(|e| format!("Failed to access directory entry: {e}")) + }) + .collect::, _>>()? + .into_iter() + .filter_map(|entry| { + if entry.path().is_file() { + Some(entry.file_name()) + } else { + None + } + }) + .collect(), + }; + + file_details + .iter() + .flat_map(|(file, file_name_info)| { + let mut file_matcher = FileMatcher::from( + file_name + .as_ref() + .map(OsString::from) + .unwrap_or(file_name_info.name.to_os_string()), + ); + if let Some(extension) = file_name_info.extension.as_ref() { + file_matcher = file_matcher.and_extension(extension); + } + + let file_name = file_name_info.to_full_file_name(); + + let add_action = + FileAction::new(file, Action::Add).expect("path points to file"); + + if pure && present_file_names.iter().any(|file| *file == file_name) { + log!( + logger, + debug, + "file is already present on {}: {}", + server.get_name(), + file_name.to_string_lossy() + ); + return vec![]; //ignore that file, since it is already present + } + + match old_version_policy { + OldVersionPolicy::Ignore => { + if !present_file_names.iter().any(|file| *file == file_name) { + vec![add_action] //file doesn't exist yet + } else { + vec![FileAction::new(file, Action::Replace).expect("path points to file")] + } + } + OldVersionPolicy::Archive => present_file_names + .iter() + .filter(|file| file_matcher.matches(file)) + .map(|file| { + FileAction::new( + file, + Action::rename( + osf!(file) + + file + .to_string_lossy() + .chars() + .last() + .unwrap_or('1') + .to_string(), + ), + ) + .expect("path points to file") + }) + .chain(once(add_action)) + .collect(), + OldVersionPolicy::Delete => { + let mut actions = present_file_names + .iter() + .filter(|file| file_matcher.matches(file)) + .map(|present_file| { + //special case -> file has the same name as current file, then we just need to replace it + if *present_file == file_name { + FileAction::new(file, Action::Replace).expect("path points to file") + } else { + FileAction::new(present_file, Action::Delete) + .expect("path points to file") + } + }) + .collect::>(); + if !actions.iter().any(|action| action.kind == Action::Replace) { + actions.push(add_action); + } + actions + } + } + }) + .collect() + }, + working_directory, + }) + }) + .collect::, String>>()? + .into_iter() + .filter(|server_actions| !server_actions.actions.is_empty()) + .collect::>(); + + if actions.is_empty() { + log!(logger, "Nothing to be done, everything is up to date"); + return Ok(()); + } + + log!(logger, "The following actions will be performed: "); + for server_actions in &actions { + log!(logger, "{server_actions}"); + log!(logger, debug, "Detailed file actions: "); + for file_action in &server_actions.actions { + log!(logger, debug, "{file_action:?}"); + } + } + + if !no_confirm && !self.confirm("Continue?", true) { + log!(logger, "Aborting..."); + return Ok(()); + } + + for server_actions in actions { + let server = server_actions.server; + log!(logger, "Performing actions on {}...", server.get_name()); + for file_action in server_actions.actions { + match file_action.kind { + Action::Add | Action::Replace => { + let source = match &file_server { + Some(file_server) => ScpParam::from(( + file_server, + file_server.server_directory_path.join(&file_action.file), + )), + None => ScpParam::from(file_action.file.as_path()), + }; + let destination = ScpParam::from((server, &server_actions.working_directory)); + ShellCommand::Scp { + source, + destination, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("upload failure: {e}"))?; + } + Action::Delete => match &server.address { + ServerAddress::Ssh { ssh_address } => { + ShellCommand::Ssh { + address: ssh_address.to_string(), + server_command: ServerCommand::Rm { + file: server_actions.working_directory.join(&file_action.file), + }, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to delete old version: {e}"))?; + } + ServerAddress::Localhost => { + fs::remove_file(server_actions.working_directory.join(&file_action.file)) + .map_err(|e| format!("failed to delete old version: {e}"))?; + } + }, + Action::Rename { new_name } => match &server.address { + ServerAddress::Ssh { ssh_address } => { + ShellCommand::Ssh { + address: ssh_address.to_string(), + server_command: ServerCommand::Mv { + source: server_actions.working_directory.join(&file_action.file), + destination: server_actions.working_directory.join(&new_name), + }, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to rename: {e}"))?; + } + ServerAddress::Localhost => { + let dir = &server_actions.working_directory; + fs::rename(dir.join(&file_action.file), dir.join(&new_name)) + .map_err(|e| format!("failed to rename: {e}"))?; + } + }, + } + } + } + + log!(logger, "Done!"); } + Command::Command { command } => { + self.start_ssh_agent(&logger)?; + Self::require_non_empty_servers(&servers)?; + for server in servers { + log!(logger, "Running command on '{}'...", server.get_name()); + match &server.address { + ServerAddress::Ssh { ssh_address } => { + ShellCommand::Ssh { + address: ssh_address.to_string(), + server_command: ServerCommand::Execute { + working_directory: server.server_directory_path.clone(), + command: OsString::from(&command), + }, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("{e}"))?; + } + ServerAddress::Localhost => { + let command = shell_words::split(&command) + .map_err(|e| format!("failed to parse command: {e}"))? + .into_iter() + .map(OsString::from) + .collect(); + ShellCommand::Execute { + working_directory: server.server_directory_path.clone(), + command, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger)?; + } + } + } + log!(logger, "Done!"); + } + Command::Editor { + file, + editor, + download_directory, + override_existing, + } => { + //determine download directory + let download_directory = match download_directory { + Some(download_directory) => download_directory, + None => { + let home_dir = self + .get_home_directory() + .map_err(|e| format!("Missing download-directory: {e}"))?; + home_dir.join("Downloads") + } + }; - log!(logger, "Done!"); + //get editor + let editor = editor.ok_or(()).or_else(|_| env::var(EDITOR_ENV_VAR).map_err(|e| format!("You have not specified an editor. Please do so using the --editor flag or the {EDITOR_ENV_VAR} environment variable: {e}")))?; + + fs::create_dir_all(&download_directory) + .map_err(|e| format!("failed to create working directory: {e}"))?; + + //make sure file doesn't exist in working directory yet, or it will be overridden + let file_name = file + .file_name() + .ok_or("can only edit files, not directories")?; + 'duplicate_check: { + if !override_existing + && fs::read_dir(&download_directory) + .map_err(|e| format!("failed to open working directory: {e}"))? + .collect::, _>>() + .map_err(|e| format!("error while querying working directory contents: {e}"))? + .iter() + .any(|entry| entry.file_name() == file_name) + { + let duplication_notification = format!( + "A file with the name {} already exists in {}", + file_name.to_string_lossy(), + download_directory.to_string_lossy() + ); + + if !args.quiet + && self.confirm( + format!("{duplication_notification}. Do you want to replace it?"), + false, + ) + { + break 'duplicate_check; + } + + return Err(format!( + "{duplication_notification}. You can override it with --override or -f" + )); + } + } + + Self::require_non_empty_servers(&servers)?; + self.start_ssh_agent(&logger)?; + + for server in servers { + log!(logger, "Getting file from {}...", server.get_name()); + let source = ScpParam::from((&server, server.server_directory_path.join(&file))); + ShellCommand::Scp { + source: source.clone(), + destination: ScpParam::from(download_directory.as_path()), + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("download failure: {e}"))?; + + //open file in editor + let editor_command = shell_words::split(&editor) + .map_err(|e| format!("failed to parse editor command: {e}"))? + .into_iter() + .map(|part| { + part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name)) + }) + .collect::>(); + + ShellCommand::Editor(editor_command) + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to open file in editor: {e}"))?; + + //upload file again + ShellCommand::Scp { + source: ScpParam::from(download_directory.join(file_name).as_path()), + destination: source, + } + .in_env(env!()) + .run_logged(&logger) + .and_expect_success() + .into_result_with_error_logging(&logger) + .map_err(|e| format!("failed to re-upload file: {e}"))?; + } + + log!(logger, "Done!"); + } + } + + Ok(()) + } + + fn check_local_file_exists

(path: P) -> Result<(), String> + where + P: AsRef, + { + let path = path.as_ref(); + if !path.is_file() { + return Err(format!( + "{} does not point to a file", + path.to_string_lossy() + )); + } + + Ok(()) + } + + fn require_non_empty_servers(servers: &[T]) -> Result<(), String> { + Self::require_non_empty(servers, "servers for this operation") + } + + fn require_non_empty(slice: &[T], slice_name: &str) -> Result<(), String> { + if slice.is_empty() { + Err(format!( + "You did not provide any {slice_name}. Please see --help" + ))? + } + Ok(()) + } + + fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> { + let env = &mut self.environment; + + //start the ssh agent + let agent_output = ShellCommand::SshAgent + .in_env(env) + .output() + .and_expect_success() + .into_result_with_error_logging(logger) + .map_err(|e| format!("Failed to start ssh agent: {e}"))?; + let agent_stdout = &agent_output + .stdout + .into_string() + .map_err(|_| "ssh-agent returned invalid utf-8 - how did this even happen?")?; + + //set the env vars from the agent + static ENV_VAR_REGEX: Lazy = lazy_regex!("(.+?)=(.+?);"); + for capture in ENV_VAR_REGEX.captures_iter(agent_stdout) { + let (_, [env_var, value]) = capture.extract(); + env.set_var(env_var, value); + } + + //add the ssh key + ShellCommand::ShhAdd + .in_env(env) + .run_logged(logger) + .and_expect_success() + .into_result_with_error_logging(logger) + .map_err(|e| format!("failed to add ssh-key: {e}"))?; + Ok(()) + } + + fn parse_server_configuration_from_env(&self) -> Result, String> { + self + .environment + .var(SERVERS_ENV_VAR) + .map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR)) + .and_then(|value| parse_server_configuration(&value, || self.get_home_directory())) + } + + fn get_home_directory(&self) -> Result { + self + .environment + .get_home_directory() + .ok_or("Failed to find your home directory".to_string()) + } + + fn confirm(&mut self, prompt: S, default_value: bool) -> bool + where + S: ToString, + { + loop { + print!( + "{}[{}]", + prompt.to_string(), + if default_value { "Y|n" } else { "y|N" } + ); + io::stdout().flush().expect("failed to flush stdout"); + let line = self + .environment + .read_line() + .expect("Failed to read console input"); + match line.to_lowercase().as_str() { + "" => return default_value, + "y" | "yes" => return true, + "n" | "no" => return false, + _ => println!("Invalid input, please choose one of the provided options"), + } } } - - Ok(()) } -fn osstring_from_ssh_output(output: Vec) -> OsString { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - OsString::from_vec(output) - } - - #[cfg(windows)] - { - use std::os::windows::ffi::OsStringExt; - OsString::from_wide(output.iter().map(|&b| b as u16).collect()) - } +fn main() -> Result<(), String> { + Application::::default().run() } -fn check_local_file_exists

(path: P) -> Result<(), String> +fn parse_server_configuration( + config_str: &str, + get_home_directory: F, +) -> Result, String> where - P: AsRef, + F: Fn() -> Result, { - let path = path.as_ref(); - if !path.is_file() { - return Err(format!( - "{} does not point to a file", - path.to_string_lossy() - )); - } - - Ok(()) -} - -#[allow(dead_code)] -fn check_file_exists_on_server( - path: P, - ssh_address: S, - server_directory: D, -) -> Result<(), String> -where - P: AsRef, - S: AsRef, - D: AsRef, -{ - let full_path = server_directory.as_ref().join(path); - match &ShellCmd::new("ssh") - .arg(ssh_address.as_ref()) - .arg(osf!("test -f ") + &full_path) - .collect_output() - { - Ok(_) => Ok(()), //file exists on file server - Err(CommandSpecificError { - error: ExecutionError::BadExitStatus(_), //test failed - .. - }) => Err(format!( - "File '{}' doesn't exist on file-server", - full_path.to_string_lossy() - )), - Err(e) => Err(format!( - "Failed to check whether file exists on file-server: {e}" - )), - } -} - -fn get_home_directory() -> Result { - homedir::my_home() - .map_err(|e| format!("Failed to determine home directory: {e}")) - .and_then(|home_dir| home_dir.ok_or("Failed to find home directory".to_string())) -} - -fn require_non_empty_servers(servers: &[T]) -> Result<(), String> { - require_non_empty(servers, "servers for this operation") -} - -fn require_non_empty(slice: &[T], slice_name: &str) -> Result<(), String> { - if slice.is_empty() { - Err(format!( - "You did not provide any {slice_name}. Please see --help" - ))? - } - Ok(()) -} - -fn start_ssh_agent(logger: &Logger) -> Result<(), String> { - //start the ssh agent - let agent_output = ShellCmd::new("ssh-agent") - .arg("-s") - .collect_output() - .map_err(|e| format!("failed to start ssh agent: {e}"))?; - let agent_stdout = String::from_utf8_lossy(&agent_output.stdout); - if !agent_output.status.success() { - return Err("failed to start ssh agent; maybe try to run ssh-agent manually?".to_string()); - } - - //set the env vars from the agent - static ENV_VAR_REGEX: Lazy = lazy_regex!("(.+?)=(.+?);"); - for capture in ENV_VAR_REGEX.captures_iter(&agent_stdout) { - let (_, [env_var, value]) = capture.extract(); - env::set_var(env_var, value); - } - - //add the ssh key - ShellCmd::new("ssh-add") - .run(logger) - .map_err(|e| format!("failed to add ssh-key: {e}"))?; - Ok(()) -} - -fn parse_server_configuration_from_env() -> Result, String> { - env::var(SERVERS_ENV_VAR) - .map_err(|_| format!("Missing environment variable {}", SERVERS_ENV_VAR)) - .and_then(|value| parse_server_configuration(&value)) -} - -fn parse_server_configuration(config_str: &str) -> Result, String> { config_str .split(',') .map(|server_entry| { - Server::from_str(server_entry, RelativeLocalPathAnker::Home) - .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) + Server::from_str( + server_entry, + RelativeLocalPathAnker::Home, + &get_home_directory, + ) + .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) }) .collect() } @@ -757,8 +808,15 @@ mod test { #[test] fn test_parse_server_configuration() { - let servers = - parse_server_configuration("foo:bar,.:fizz/buzz").expect("valid server configuration"); + //setup directory structure for test + let home_dir = PathBuf::from("target/test"); + const LOCAL_SERVER_DIR: &str = "fizz/buzz"; + fs::create_dir_all(home_dir.join(LOCAL_SERVER_DIR)).expect("failed to create server directory"); + + let servers = parse_server_configuration(&format!("foo:bar,.:{LOCAL_SERVER_DIR}"), || { + Ok(home_dir.clone()) + }) + .expect("valid server configuration"); assert_eq!( vec![ Server { @@ -769,7 +827,10 @@ mod test { }, Server { address: ServerAddress::Localhost, - server_directory_path: PathBuf::from("fizz/buzz"), + server_directory_path: home_dir + .join(LOCAL_SERVER_DIR) + .canonicalize() + .expect("home dir exists"), } ], servers diff --git a/src/server.rs b/src/server.rs index 2d1ebdc..7c62268 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,3 @@ -use crate::get_home_directory; use std::cell::LazyCell; use std::error::Error; use std::fmt::{Display, Formatter}; @@ -6,7 +5,6 @@ use std::fs; use std::hash::{Hash, Hasher}; use std::ops::Deref; use std::path::PathBuf; -use std::str::FromStr; #[derive(Debug, Clone)] pub enum ServerReference { @@ -15,6 +13,19 @@ pub enum ServerReference { } impl ServerReference { + pub fn from_str(s: &str, get_home_directory: F) -> Result + where + F: FnOnce() -> Result, + { + Server::from_str( + s, + RelativeLocalPathAnker::CurrentDirectory, + get_home_directory, + ) + .map(Self::Resolved) + .or_else(|_| Ok(Self::Identifier(s.to_string()))) + } + pub fn get_identifier(&self) -> &str { match self { ServerReference::Resolved(server) => server.address.identifier(), @@ -68,16 +79,6 @@ impl ServerReference { } } -impl FromStr for ServerReference { - type Err = ServerReferenceParseError; - - fn from_str(s: &str) -> Result { - Server::from_str(s, RelativeLocalPathAnker::CurrentDirectory) - .map(Self::Resolved) - .or_else(|_| Ok(Self::Identifier(s.to_string()))) - } -} - impl PartialEq for ServerReference { fn eq(&self, other: &Self) -> bool { self.get_identifier() == other.get_identifier() @@ -126,10 +127,14 @@ impl Server { } } - pub fn from_str( + pub fn from_str( s: &str, relative_local_path_anker: RelativeLocalPathAnker, - ) -> Result { + get_home_directory: F, + ) -> Result + where + F: FnOnce() -> Result, + { s.split_once(':') .ok_or(ServerParseError::MissingServerDirectory) .and_then(|(identifier, server_directory)| { @@ -245,13 +250,12 @@ impl Error for ServerParseError {} mod test_server_reference { use crate::server::{Server, ServerAddress, ServerReference}; use std::path::PathBuf; - use std::str::FromStr; #[test] fn test_from_str() { assert_eq!( ServerReference::Identifier("foo".to_string()), - ServerReference::from_str("foo").unwrap() + ServerReference::from_str("foo", || panic!("shouldn't be called")).unwrap() ); assert_eq!( ServerReference::Resolved(Server { @@ -260,7 +264,7 @@ mod test_server_reference { }, server_directory_path: PathBuf::from("server/creative2") }), - ServerReference::from_str("crea:server/creative2").unwrap() + ServerReference::from_str("crea:server/creative2", || panic!("shouldn't be called")).unwrap() ); } } diff --git a/src/shell_interface.rs b/src/shell_interface.rs new file mode 100644 index 0000000..1bf318d --- /dev/null +++ b/src/shell_interface.rs @@ -0,0 +1,562 @@ +use crate::logger::{LogLevel, Logger}; +use crate::server::{Server, ServerAddress}; +use crate::{log, osf}; +use std::error::Error; +use std::ffi::OsString; +use std::fmt::{Debug, Display, Formatter}; +use std::iter::once; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::{io, process}; + +#[derive(Debug)] +pub struct EnvCommand<'a, E> { + command: ShellCommand, + environment: &'a mut E, +} + +impl EnvCommand<'_, E> +where + E: ShellInterface, +{ + pub fn run(self) -> CommandResult { + self.environment.run_command(self.command) + } + + pub fn output(self) -> CommandResult { + self.environment.collect_command_output(self.command) + } + + pub fn run_logged(self, logger: &Logger) -> CommandResult + where + Self: Sized, + { + match logger.level { + LogLevel::Debug | LogLevel::Info => { + let res = self.run(); + CommandResult { + result: res.result.map(LoggedRunOutput::from), + command: res.command, + } + } + LogLevel::Error => { + let res = self.output(); + CommandResult { + result: res.result.map(LoggedRunOutput::from), + command: res.command, + } + } + } + } +} + +#[derive(Debug, Clone)] +pub enum ShellCommand { + Ssh { + address: String, + server_command: ServerCommand, + }, + Scp { + source: ScpParam, + destination: ScpParam, + }, + SshAgent, + ShhAdd, + Editor(Vec), + Execute { + working_directory: PathBuf, + command: Vec, + }, +} + +impl ShellCommand { + pub fn in_env(self, environment: &mut E) -> EnvCommand { + EnvCommand { + command: self, + environment, + } + } +} + +#[derive(Debug, Clone)] +pub enum ServerCommand { + Realpath { + path: PathBuf, + }, + Ls { + dir: PathBuf, + }, + Rm { + file: PathBuf, + }, + Mv { + source: PathBuf, + destination: PathBuf, + }, + Execute { + working_directory: PathBuf, + command: OsString, + }, +} + +#[derive(Debug, Clone)] +pub struct ScpParam { + pub server: Option, + pub path: PathBuf, +} + +impl

From<(&Server, P)> for ScpParam +where + P: AsRef, +{ + fn from((server, path): (&Server, P)) -> Self { + Self { + server: match &server.address { + ServerAddress::Ssh { ssh_address } => Some(ssh_address.into()), + ServerAddress::Localhost => None, + }, + path: PathBuf::from(path.as_ref()), + } + } +} + +impl From<&Path> for ScpParam { + fn from(value: &Path) -> Self { + Self { + server: None, + path: PathBuf::from(value), + } + } +} + +impl From<&ScpParam> for OsString { + fn from(value: &ScpParam) -> Self { + let mut builder = osf!(); + if let Some(server) = &value.server { + builder += format!("{server}:"); + } + builder += &value.path; + builder.build() + } +} + +impl Display for ShellCommand { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + command_to_string(&build_command_from_shell_command(self)) + ) + } +} + +fn command_to_string(command: &Command) -> String { + once(command.get_program().to_string_lossy().to_string()) + .chain(command.get_args().map(|arg| { + let arg_str = arg.to_string_lossy(); + if arg_str.contains(' ') { + format!("\"{arg_str}\"") + } else { + arg_str.to_string() + } + })) + .collect::>() + .join(" ") +} + +macro_rules! cmd { + ($programm: expr $(, $arg:expr )*)=> {{ + #[allow(unused_mut)] + let mut cmd = Command::new($programm); + $( cmd.arg($arg); )* + cmd + }}; +} + +pub fn build_command_from_shell_command(shell_command: &ShellCommand) -> Command { + match shell_command { + ShellCommand::Ssh { + address, + server_command, + } => cmd!( + "ssh", + address, + match server_command { + ServerCommand::Realpath { path } => osf!("realpath -e ") + path, + ServerCommand::Ls { dir } => osf!("ls ") + dir, + ServerCommand::Rm { file } => osf!("rm ") + file, + ServerCommand::Mv { + source, + destination, + } => osf!("mv ") + source + " " + destination, + ServerCommand::Execute { + working_directory, + command, + } => osf!("cd ") + working_directory + "; " + command, + } + ), + ShellCommand::Scp { + source, + destination, + } => cmd!("scp", OsString::from(source), OsString::from(destination)), + ShellCommand::SshAgent => cmd!("ssh-agent", "-s"), + ShellCommand::ShhAdd => cmd!("ssh-add"), + ShellCommand::Editor(args) => { + let mut args = args.clone(); + let mut cmd = cmd!(args.remove(0)); + cmd.args(args); + cmd + } + ShellCommand::Execute { + working_directory, + command, + } => { + let mut args = command.clone(); + let mut cmd = cmd!(args.remove(0)); + cmd.args(args).current_dir(working_directory); + cmd + } + } +} + +pub trait ShellInterface { + fn run_command(&mut self, command: ShellCommand) -> CommandResult; + fn collect_command_output( + &mut self, + command: ShellCommand, + ) -> CommandResult; +} + +pub trait MaybeCast { + fn maybe_cast(&self) -> Option<&T>; +} + +impl MaybeCast for T { + fn maybe_cast(&self) -> Option<&T> { + Some(self) + } +} + +#[derive(Debug)] +pub enum LoggedRunOutput { + ExitStatus(ExitStatus), + CommandOutput(CommandOutput), +} + +impl From for LoggedRunOutput { + fn from(value: ExitStatus) -> Self { + Self::ExitStatus(value) + } +} + +impl From for LoggedRunOutput { + fn from(value: CommandOutput) -> Self { + Self::CommandOutput(value) + } +} + +impl AsRef for LoggedRunOutput { + fn as_ref(&self) -> &ExitStatus { + match self { + LoggedRunOutput::ExitStatus(status) => status, + LoggedRunOutput::CommandOutput(output) => output.as_ref(), + } + } +} + +impl MaybeCast for LoggedRunOutput { + fn maybe_cast(&self) -> Option<&CommandOutput> { + match self { + LoggedRunOutput::ExitStatus(_) => None, + LoggedRunOutput::CommandOutput(output) => Some(output), + } + } +} + +#[derive(Debug)] +pub struct CommandResult { + pub command: ShellCommand, + pub result: Result, +} + +impl CommandResult { + pub fn into_result(self) -> Result> { + self.result.map_err(|error| CommandError { + command: self.command, + error, + }) + } +} + +impl CommandResult { + pub fn and_expect_success(self) -> CommandResult> + where + T: AsRef, + { + CommandResult { + result: self.result.map_err(ExecutionError::from).and_then(|t| { + if t.as_ref().success { + Ok(t) + } else { + Err(ExecutionError::BadExitStatus(t)) + } + }), + command: self.command, + } + } +} + +impl CommandResult> { + pub fn into_result_with_error_logging( + self, + logger: &Logger, + ) -> Result>> + where + T: MaybeCast, + { + self.result.map_err(|error| { + if let ExecutionError::BadExitStatus(t) = &error { + if let Some(output) = t.maybe_cast() { + log!(logger, error, "{}", output.stdout.to_string_lossy()); + log!(logger, error, "{}", output.stderr.to_string_lossy()); + } + } + CommandError { + command: self.command, + error, + } + }) + } +} + +#[derive(Debug, Clone)] +pub struct CommandOutput { + pub stdout: OsString, + pub stderr: OsString, + pub status: ExitStatus, +} + +impl From for CommandOutput { + fn from(value: Output) -> Self { + Self { + stdout: os_string_from_ssh_output(value.stdout), + stderr: os_string_from_ssh_output(value.stderr), + status: value.status.into(), + } + } +} + +pub fn os_string_from_ssh_output(output: Vec) -> OsString { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + OsString::from_vec(output) + } + + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + OsString::from_wide(output.iter().map(|&b| b as u16).collect()) + } +} + +impl AsRef for CommandOutput { + fn as_ref(&self) -> &ExitStatus { + &self.status + } +} + +#[derive(Debug, Clone)] +pub struct ExitStatus { + pub success: bool, + pub string_form: String, + #[allow(dead_code)] + pub code: Option, +} + +impl From for ExitStatus { + fn from(value: process::ExitStatus) -> Self { + Self { + success: value.success(), + string_form: value.to_string(), + code: value.code(), + } + } +} + +impl AsRef for ExitStatus { + fn as_ref(&self) -> &ExitStatus { + self + } +} + +impl Display for ExitStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.string_form, f) + } +} + +#[derive(Debug)] +pub struct CommandError { + pub command: ShellCommand, + pub error: E, +} + +impl From> for String +where + E: Display, +{ + fn from(value: CommandError) -> Self { + value.to_string() + } +} + +impl Display for CommandError +where + E: Display, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Error while running command '{}': {}", + self.command, self.error + ) + } +} + +impl Error for CommandError where E: Error {} + +#[derive(Debug)] +pub struct StartError(io::Error); + +impl From for StartError { + fn from(value: io::Error) -> Self { + StartError(value) + } +} + +impl Display for StartError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Failed to run command: {}", self.0) + } +} + +impl Error for StartError {} + +#[derive(Debug)] +pub enum ExecutionError { + StartError(StartError), + BadExitStatus(T), +} + +impl From for ExecutionError { + fn from(value: StartError) -> Self { + Self::StartError(value) + } +} + +impl Display for ExecutionError +where + T: AsRef, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ExecutionError::StartError(e) => Display::fmt(e, f), + ExecutionError::BadExitStatus(status) => { + write!(f, "execution failed with {}", status.as_ref()) + } + } + } +} + +impl Error for ExecutionError where T: AsRef + Debug {} + +#[cfg(test)] +mod test_commands { + use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand}; + use std::path::PathBuf; + + #[test] + fn test_to_string() { + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Realpath { + path: PathBuf::from("plugins/*.jar") + } + } + .to_string(), + r#"ssh crea "realpath -e plugins/*.jar""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Ls { + dir: PathBuf::from("creative/plugins") + } + } + .to_string(), + r#"ssh crea "ls creative/plugins""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Rm { + file: PathBuf::from("foo.txt") + }, + } + .to_string(), + r#"ssh crea "rm foo.txt""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Mv { + source: PathBuf::from("foo"), + destination: PathBuf::from("bar") + } + } + .to_string(), + r#"ssh crea "mv foo bar""# + ); + assert_eq!( + ShellCommand::Ssh { + address: "crea".to_string(), + server_command: ServerCommand::Execute { + working_directory: PathBuf::from(".."), + command: "sudo rm -rf *".into(), + } + } + .to_string(), + r#"ssh crea "cd ..; sudo rm -rf *""# + ); + assert_eq!( + ShellCommand::Scp { + source: ScpParam { + server: None, + path: PathBuf::from("target/mssh") + }, + destination: ScpParam { + server: Some("crea".into()), + path: PathBuf::from("/usr/bin") + }, + } + .to_string(), + r#"scp target/mssh crea:/usr/bin"# + ); + assert_eq!(ShellCommand::SshAgent.to_string(), r#"ssh-agent -s"#); + assert_eq!(ShellCommand::ShhAdd.to_string(), r#"ssh-add"#); + assert_eq!( + ShellCommand::Editor(vec!["kate".into(), "-b".into(), "test.txt".into()]).to_string(), + r#"kate -b test.txt"# + ); + assert_eq!( + ShellCommand::Execute { + working_directory: PathBuf::from("/home/me/server"), + command: vec!["java".into(), "-jar".into(), "paper.jar".into()] + } + .to_string(), + r#"java -jar paper.jar"# + ); + } +} diff --git a/test-ressources/python/exit_1.py b/test-ressources/python/exit_1.py deleted file mode 100644 index b11a11b..0000000 --- a/test-ressources/python/exit_1.py +++ /dev/null @@ -1 +0,0 @@ -exit(1) \ No newline at end of file