Merge pull request '20-dot-and-wildcard-paths-are-not-correctly-resolved-when-reffering-to-remote-files-or-ssh-targets' (#21) from 20-dot-and-wildcard-paths-are-not-correctly-resolved-when-reffering-to-remote-files-or-ssh-targets into master

Reviewed-on: https://stupstech.de/dev/Mr_Steppy/multi-ssh/pulls/21
This commit is contained in:
Leonard Steppy 2025-02-03 13:25:39 +01:00
commit 1633636cab
4 changed files with 176 additions and 61 deletions

View File

@ -1,26 +1,34 @@
use crate::log;
use crate::logger::{LogLevel, Logger};
use std::error::Error;
use std::fmt::{Display, Formatter};
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<(), SpecificExecutionError>;
fn collect_output(&mut self) -> Result<Output, SpecificExecutionError>;
fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError<ExecutionError>>;
fn collect_output(&mut self) -> Result<Output, CommandSpecificError<ExecutionError>>;
fn collect_full_output(&mut self) -> Result<Output, CommandSpecificError<StartError>>;
}
impl LogRunnable for Command {
fn run(&mut self, logger: &Logger) -> Result<(), SpecificExecutionError> {
run(self, logger).map_err(|error| SpecificExecutionError {
fn run(&mut self, logger: &Logger) -> Result<(), CommandSpecificError<ExecutionError>> {
run(self, logger).map_err(|error| CommandSpecificError {
command: self,
error,
})
}
fn collect_output(&mut self) -> Result<Output, SpecificExecutionError> {
collect_output(self, None).map_err(|error| SpecificExecutionError {
fn collect_output(&mut self) -> Result<Output, CommandSpecificError<ExecutionError>> {
collect_output(self, None).map_err(|error| CommandSpecificError {
command: self,
error,
})
}
fn collect_full_output(&mut self) -> Result<Output, CommandSpecificError<StartError>> {
collect_full_output(self).map_err(|error| CommandSpecificError {
command: self,
error,
})
@ -46,7 +54,7 @@ fn collect_output(
command: &mut Command,
logger: Option<&Logger>,
) -> Result<Output, ExecutionError> {
let output = command.output()?; //pipes stdout and stderr automatically
let output = collect_full_output(command)?;
if !output.status.success() {
if let Some(logger) = logger {
log!(logger, error, "{}", String::from_utf8_lossy(&output.stdout));
@ -57,13 +65,20 @@ fn collect_output(
Ok(output)
}
#[derive(Debug)]
pub struct SpecificExecutionError<'a> {
pub command: &'a Command,
pub error: ExecutionError,
fn collect_full_output(command: &mut Command) -> Result<Output, StartError> {
Ok(command.output()?)
}
impl Display for SpecificExecutionError<'_> {
#[derive(Debug)]
pub struct CommandSpecificError<'a, E> {
pub command: &'a Command,
pub error: E,
}
impl<E> Display for CommandSpecificError<'_, E>
where
E: Display,
{
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
@ -75,22 +90,52 @@ impl Display for SpecificExecutionError<'_> {
}
fn command_to_string(command: &Command) -> String {
once(command.get_program().to_string_lossy())
.chain(command.get_args().map(|arg| arg.to_string_lossy()))
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::<Vec<_>>()
.join(" ")
}
impl Error for SpecificExecutionError<'_> {}
impl<E> Error for CommandSpecificError<'_, E> where E: Debug + Display {}
#[derive(Debug)]
pub struct StartError(io::Error);
impl From<io::Error> 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(io::Error),
StartError(StartError),
BadExitStatus(ExitStatus),
}
impl From<io::Error> for ExecutionError {
fn from(value: io::Error) -> Self {
Self::StartError(StartError(value))
}
}
impl From<StartError> for ExecutionError {
fn from(value: StartError) -> Self {
Self::StartError(value)
}
}
@ -104,10 +149,8 @@ impl From<ExitStatus> for ExecutionError {
impl Display for ExecutionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ExecutionError::StartError(e) => write!(f, "Failed to start command: {}", e),
ExecutionError::BadExitStatus(status) => {
write!(f, "Command failed with {}", status)
}
ExecutionError::StartError(e) => Display::fmt(e, f),
ExecutionError::BadExitStatus(status) => write!(f, "Command failed with {}", status),
}
}
}
@ -116,7 +159,7 @@ impl Error for ExecutionError {}
#[cfg(test)]
mod test {
use crate::command::{ExecutionError, LogRunnable, SpecificExecutionError};
use crate::command::{CommandSpecificError, ExecutionError, LogRunnable};
use crate::logger::Logger;
use std::path::PathBuf;
use std::process::Command;
@ -125,7 +168,7 @@ mod test {
fn test_unknown_command() {
let mut command = Command::new("python7");
let Err(
e @ SpecificExecutionError {
e @ CommandSpecificError {
error: ExecutionError::StartError(_),
..
},
@ -142,7 +185,7 @@ mod test {
fn test_error() {
let mut command = Command::new("python3");
let Err(
e @ SpecificExecutionError {
e @ CommandSpecificError {
error: ExecutionError::BadExitStatus(_),
..
},

View File

@ -6,18 +6,20 @@ mod os_string_builder;
mod server;
use crate::action::{Action, FileAction, ServerActions};
use crate::command::{ExecutionError, LogRunnable, SpecificExecutionError};
use crate::command::{CommandSpecificError, ExecutionError, LogRunnable};
use crate::file::{FileMatcher, FileNameInfo};
use crate::logger::{LogLevel, Logger};
use crate::os_string_builder::ReplaceWithOsStr;
use crate::server::ServerAddress;
use crate::server::{RelativeLocalPathAnker, ServerAddress};
use clap::{Parser, Subcommand, ValueEnum};
use lazy_regex::{lazy_regex, Lazy, Regex};
use server::{Server, ServerReference};
use std::cell::LazyCell;
use std::ffi::OsStr;
use std::hash::Hash;
use std::io::Write;
use std::iter::once;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{env, fs, io};
@ -178,14 +180,15 @@ fn main() -> Result<(), String> {
match args.command {
Command::Upload {
files,
mut files,
file_server,
old_version_policy,
upload_directory,
mut upload_directory,
no_confirm,
file_name,
} => {
require_non_empty_servers(&servers)?;
require_non_empty(&files, "files to upload")?;
start_ssh_agent(&logger)?;
//resolve file server
@ -204,21 +207,44 @@ fn main() -> Result<(), String> {
match &file_server {
Some(file_server) => match &file_server.address {
ServerAddress::Ssh { ssh_address } => {
for file in &files {
check_file_exists_on_server(file, ssh_address, &file_server.server_directory_path)?;
}
}
ServerAddress::Localhost => {
for file in &files {
check_local_file_exists(file_server.server_directory_path.join(file))?;
}
//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()
.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| b == b'\n') //split at line breaks
.filter(|bytes| !bytes.is_empty()) //needed since realpath sometimes gives us empty lines
.map(|bytes| PathBuf::from(OsStr::from_bytes(bytes)))
.collect::<Vec<_>>();
Ok(denoted_files)
})
.collect::<Result<Vec<_>, String>>()?
.into_iter()
.flatten()
.collect();
log!(logger, debug, "canonical files: {files:?}");
}
ServerAddress::Localhost => files
.iter()
.map(|file| file_server.server_directory_path.join(file))
.try_for_each(check_local_file_exists)?,
},
None => {
for file in &files {
check_local_file_exists(file)?;
}
}
None => files.iter().try_for_each(check_local_file_exists)?,
}
let file_details = files
@ -234,6 +260,15 @@ fn main() -> Result<(), String> {
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,
@ -535,6 +570,7 @@ where
Ok(())
}
#[allow(dead_code)]
fn check_file_exists_on_server<P, S, D>(
path: P,
ssh_address: S,
@ -552,11 +588,11 @@ where
.collect_output()
{
Ok(_) => Ok(()), //file exists on file server
Err(SpecificExecutionError {
Err(CommandSpecificError {
error: ExecutionError::BadExitStatus(_), //test failed
..
}) => Err(format!(
"File '{}' doesn't exist on file server",
"File '{}' doesn't exist on file-server",
full_path.to_string_lossy()
)),
Err(e) => Err(format!(
@ -571,12 +607,17 @@ fn get_home_directory() -> Result<PathBuf, String> {
.and_then(|home_dir| home_dir.ok_or("Failed to find home directory".to_string()))
}
fn require_non_empty_servers(servers: &[Server]) -> Result<(), String> {
if servers.is_empty() {
Err("You did not provide any servers for this operation. Please see --help".to_string())
} else {
Ok(())
fn require_non_empty_servers<T>(servers: &[T]) -> Result<(), String> {
require_non_empty(servers, "servers for this operation")
}
fn require_non_empty<T>(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> {
@ -614,7 +655,7 @@ fn parse_server_configuration(config_str: &str) -> Result<Vec<Server>, String> {
config_str
.split(',')
.map(|server_entry| {
Server::from_str(server_entry)
Server::from_str(server_entry, RelativeLocalPathAnker::Home)
.map_err(|e| format!("Invalid server entry '{server_entry}': {e}"))
})
.collect()
@ -658,6 +699,7 @@ mod test {
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")
@ -665,4 +707,10 @@ mod test {
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");
}
}

View File

@ -2,6 +2,7 @@ use std::ffi::{OsStr, OsString};
use std::fmt::{Debug, Formatter};
use std::hash::{Hash, Hasher};
use std::ops::{Add, AddAssign};
use std::path::Path;
pub trait ReplaceWithOsStr<'a, Pattern = &'a str> {
#[must_use]
@ -56,6 +57,12 @@ impl AsRef<OsStr> for OsStringBuilder {
}
}
impl AsRef<Path> for OsStringBuilder {
fn as_ref(&self) -> &Path {
self.result.as_ref()
}
}
impl<P> Add<P> for OsStringBuilder
where
P: AsRef<OsStr>,

View File

@ -2,6 +2,7 @@ use crate::get_home_directory;
use std::cell::LazyCell;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs;
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use std::path::PathBuf;
@ -71,7 +72,7 @@ impl FromStr for ServerReference {
type Err = ServerReferenceParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Server::from_str(s)
Server::from_str(s, RelativeLocalPathAnker::CurrentDirectory)
.map(Self::Resolved)
.or_else(|_| Ok(Self::Identifier(s.to_string())))
}
@ -124,22 +125,28 @@ impl Server {
ServerAddress::Localhost => "this computer",
}
}
}
impl FromStr for Server {
type Err = ServerParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
pub fn from_str(
s: &str,
relative_local_path_anker: RelativeLocalPathAnker,
) -> Result<Self, ServerParseError> {
s.split_once(':')
.ok_or(ServerParseError::MissingServerDirectory)
.and_then(|(identifier, server_directory)| {
let address = ServerAddress::from_str(identifier);
let mut server_directory_path = PathBuf::from(server_directory);
if let ServerAddress::Localhost = &address {
let home_directory = get_home_directory()
.map_err(|e| ServerParseError::HomeDirectoryRequired { detail_message: e })?;
server_directory_path = home_directory.join(&server_directory_path);
}
let server_directory_path = match &address {
ServerAddress::Ssh { .. } => PathBuf::from(server_directory),
ServerAddress::Localhost => fs::canonicalize(match relative_local_path_anker {
RelativeLocalPathAnker::Home => {
let home_directory = get_home_directory()
.map_err(|e| ServerParseError::HomeDirectoryRequired { detail_message: e })?;
home_directory.join(server_directory)
}
RelativeLocalPathAnker::CurrentDirectory => PathBuf::from(server_directory),
})
.map_err(|_| ServerParseError::ServerDirectoryNonExistent)?,
};
Ok(Self {
address,
server_directory_path,
@ -162,6 +169,12 @@ impl Display for Server {
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum RelativeLocalPathAnker {
Home,
CurrentDirectory,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum ServerAddress {
Ssh { ssh_address: String },
@ -201,6 +214,7 @@ impl ServerAddress {
#[derive(Debug)]
pub enum ServerParseError {
MissingServerDirectory,
ServerDirectoryNonExistent,
HomeDirectoryRequired { detail_message: String },
}
@ -218,6 +232,9 @@ impl Display for ServerParseError {
f,
"localhost requires home directory, but: {detail_message}"
),
ServerParseError::ServerDirectoryNonExistent => {
write!(f, "The specified server directory doesn't exist")
}
}
}
}