mod action; mod environment; mod file; mod logger; mod os_str_extension; mod os_string_builder; mod server; mod shell_interface; use crate::action::{Action, FileAction, ServerActions}; 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}; use std::cell::LazyCell; use std::ffi::OsString; use std::hash::Hash; use std::io::Write; use std::iter::once; use std::path::{Path, PathBuf}; use std::{env, fs, io}; const SERVERS_ENV_VAR: &str = "MSSH_SERVERS"; const EDITOR_ENV_VAR: &str = "MSSH_EDITOR"; const FILE_PLACEHOLDER: &str = ""; /// 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 /// a double colon: crea:home/crea. /// /// --- Configuration via environment variable --- /// /// Use `MSSH_SERVERS="crea:home/crea,sky:sky,lobby:,city:city2"` to configure servers. #[derive(Parser, Debug)] #[command(version, about, long_about)] 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..)] servers: Vec, /// How verbose logging output should be #[arg(long, default_value = "info", conflicts_with_all = ["quiet", "info"])] log_level: LogLevel, /// Only log errors #[arg(short, long, default_value = "false", conflicts_with_all = ["info"])] quiet: bool, /// Log additional debugging info #[arg(short = 'v', long, default_value = "false")] info: bool, } #[derive(Subcommand, Debug)] enum Command { /// Upload a file to the servers #[command(visible_short_flag_alias = 'u')] Upload { /// The files to upload files: Vec, /// The ssh server to get the file from. /// /// 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, /// 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, /// The directory where to upload to, relative to the server directory #[arg(short = 'd', long, default_value = "plugins")] upload_directory: PathBuf, /// Skip the confirmation dialog #[arg(long, default_value = "false")] no_confirm: bool, /// The prefix of the name of older versions of the file, which should be replaced or deleted #[arg(short, long)] file_name: Option, /// Only upload files which are not present yet on the target server #[arg(short, long, default_value = "false")] pure: bool, /// The prefixes of the names of files not to upload #[arg(short, long, num_args = 0..)] exclude: Vec, }, /// Execute a command on the servers #[command(visible_short_flag_alias = 'c')] #[allow(clippy::enum_variant_names)] Command { /// The command to execute command: String, }, /// Open a file in your local editor and upload it afterward. /// /// The editor will be opened for every server in the order provided by you. /// The command to start the editor can be configured via environment variable `MSSH_EDITOR`: /// /// `export MSSH_EDITOR="nano "` /// /// File is a placeholder that will be replaced with the actual name of the file. #[command(visible_short_flag_alias = 'e')] Editor { /// A path to the file to edit, relative to the server directory file: PathBuf, /// The command to start the editor. Supports the placeholder ``, e.g. "nano ". /// /// If omitted, the command will be taken from the environment variable `MSSH_EDITOR`. #[arg(short, long)] editor: Option, /// The directory where to save the file to. /// /// Default directory is `~/Downloads` #[arg(short = 'd', long)] download_directory: Option, /// Override existing files in the working directory #[arg(short = 'f', long = "override", default_value = "false")] override_existing: bool, }, } #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default, ValueEnum)] enum OldVersionPolicy { /// Ignore the existence of older versions Ignore, /// Rename older versions: foo.jar -> foo.jarr Archive, /// Delete older versions #[default] Delete, } #[derive(Debug, Default)] pub struct Application { pub environment: E, } impl Application where E: Environment, { 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) } pub fn run_with_args(&mut self, args: Args) -> Result<(), String> { macro_rules! env { () => { &mut self.environment }; } 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 = 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::>(); 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(Self::check_local_file_exists)?, }, None => files.iter().try_for_each(Self::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 } => 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") } }; //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"), } } } } fn main() -> Result<(), String> { Application::::default().run() } fn parse_server_configuration( config_str: &str, get_home_directory: F, ) -> Result, String> where F: Fn() -> Result, { config_str .split(',') .map(|server_entry| { Server::from_str( server_entry, RelativeLocalPathAnker::Home, &get_home_directory, ) .map_err(|e| format!("Invalid server entry '{server_entry}': {e}")) }) .collect() } #[cfg(test)] mod test { use crate::parse_server_configuration; use crate::server::{Server, ServerAddress}; use std::fs; use std::path::PathBuf; #[test] fn test_parse_server_configuration() { let servers = parse_server_configuration("foo:bar,.:fizz/buzz", || Ok(PathBuf::from("/test"))) .expect("valid server configuration"); assert_eq!( vec![ Server { address: ServerAddress::Ssh { ssh_address: "foo".to_string() }, server_directory_path: PathBuf::from("bar"), }, Server { address: ServerAddress::Localhost, server_directory_path: PathBuf::from("/test/fizz/buzz"), } ], servers ); } /// When we join an absolute path to a relative path, it becomes a relative path #[test] fn path_experiment() { let server_dir = PathBuf::from("steptech"); let upload_dir = PathBuf::from("/home"); //absolute path let joined = server_dir.join(upload_dir); assert_eq!(PathBuf::from("/home"), joined); } /// When renaming a file in a folder, the folder is relevant in the new name #[test] fn rename_experiment() { fs::rename("test-ressources/files/test", "test-ressources/files/test1") .expect("failed to rename test file"); fs::rename("test-ressources/files/test1", "test-ressources/files/test") .expect("failed to rename test1 file back to test"); } #[test] fn mkdir_experiment() { fs::create_dir_all("./test-ressources/files/../python") .expect("failed to create directory with relative path"); } }