From ba44c80211bee76595f00adfc037312f8fbff266 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Fri, 14 Apr 2023 15:53:30 +0200 Subject: [PATCH] Remote access works --- Cargo.lock | 202 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/library/beets.rs | 100 ++++++++++++++++++--- src/library/mod.rs | 34 ++++---- src/main.rs | 50 ++++++----- 5 files changed, 339 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31f9a84..d4320f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + [[package]] name = "cassowary" version = "0.3.0" @@ -107,6 +113,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "downcast" version = "0.11.0" @@ -164,6 +190,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +[[package]] +name = "getrandom" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "heck" version = "0.3.3" @@ -312,11 +349,13 @@ dependencies = [ "crossterm", "mockall", "once_cell", + "openssh", "ratatui", "serde", "serde_json", "structopt", "tempfile", + "tokio", "uuid", ] @@ -341,6 +380,39 @@ version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +[[package]] +name = "openssh" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca6c277973fb549b36dd8980941b5ea3ecebea026f5b1f0060acde74d893c22" +dependencies = [ + "dirs", + "libc", + "once_cell", + "openssh-mux-client", + "shell-escape", + "tempfile", + "thiserror", + "tokio", + "tokio-pipe", +] + +[[package]] +name = "openssh-mux-client" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88eac793af6170bcd6d4f39c3b7ba3f4227cab5680d7189ba30f9d174600b75f" +dependencies = [ + "once_cell", + "sendfd", + "serde", + "ssh_format", + "thiserror", + "tokio", + "tokio-io-utility", + "typed-builder", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -364,6 +436,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + [[package]] name = "predicates" version = "2.1.5" @@ -467,6 +545,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall 0.2.16", + "thiserror", +] + [[package]] name = "regex" version = "1.7.3" @@ -510,6 +599,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sendfd" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604b71b8fc267e13bb3023a2c901126c8f349393666a6d98ac1ae5729b701798" +dependencies = [ + "libc", + "tokio", +] + [[package]] name = "serde" version = "1.0.159" @@ -541,6 +640,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-escape" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" + [[package]] name = "signal-hook" version = "0.3.15" @@ -577,6 +682,25 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ssh_format" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8701239872766d43b8a5f9a560ff7f002b48064fadea87f44a70507069fb482" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.8.0" @@ -657,6 +781,84 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.11", +] + +[[package]] +name = "tokio" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" +dependencies = [ + "autocfg", + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-io-utility" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d672654d175710e52c7c41f6aec77c62b3c0954e2a7ebce9049d1e94ed7c263" +dependencies = [ + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.11", +] + +[[package]] +name = "tokio-pipe" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f213a84bffbd61b8fa0ba8a044b4bbe35d471d0b518867181e82bd5c15542784" +dependencies = [ + "libc", + "tokio", +] + +[[package]] +name = "typed-builder" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "unicode-ident" version = "1.0.8" diff --git a/Cargo.toml b/Cargo.toml index 485339f..4e1b299 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,12 @@ edition = "2021" [dependencies] crossterm = "0.26.1" +openssh = { version = "0.9.9", features = ["native-mux"], default-features = false } serde = { version = "1.0.159", features = ["derive"] } serde_json = "1.0.95" structopt = "0.3.26" ratatui = "0.20.1" +tokio = { version = "1.27.0", features = ["rt"] } uuid = { version = "1.3.0", features = ["serde"] } [dev-dependencies] diff --git a/src/library/beets.rs b/src/library/beets.rs index d854587..1e0e29f 100644 --- a/src/library/beets.rs +++ b/src/library/beets.rs @@ -5,10 +5,13 @@ use std::{ collections::{HashMap, HashSet}, ffi::OsString, path::PathBuf, - process::Command, + process::{Command, Output}, str, }; +use openssh::{KnownHosts, Session}; +use tokio::runtime::{self, Runtime}; + #[cfg(test)] use mockall::automock; @@ -22,6 +25,7 @@ macro_rules! list_format_separator { }; } +const BEET_DEFAULT: &str = "beet"; const CMD_LIST: &str = "ls"; const LIST_FORMAT_SEPARATOR: &str = list_format_separator!(); const LIST_FORMAT_ARG: &str = concat!( @@ -198,6 +202,18 @@ impl LibraryPrivate for BeetsLibrary { } } +trait BeetsLibraryExecutorPrivate { + fn output(output: Output) -> Result, Error> { + if !output.status.success() { + return Err(Error::Executor( + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + let output = str::from_utf8(&output.stdout)?; + Ok(output.split('\n').map(|s| s.to_string()).collect()) + } +} + /// Beets library executor that executes beets commands in their own process. pub struct BeetsLibraryProcessExecutor { bin: OsString, @@ -205,8 +221,13 @@ pub struct BeetsLibraryProcessExecutor { } impl BeetsLibraryProcessExecutor { + /// Create a new [`BeetsLibraryProcessExecutor`] that uses the default beets executable. + pub fn new() -> Self { + Self::bin(BEET_DEFAULT) + } + /// Create a new [`BeetsLibraryProcessExecutor`] that uses the provided beets executable. - pub fn new>(bin: S) -> Self { + pub fn bin>(bin: S) -> Self { BeetsLibraryProcessExecutor { bin: bin.into(), config: None, @@ -224,7 +245,7 @@ impl Default for BeetsLibraryProcessExecutor { /// Create a new [`BeetsLibraryProcessExecutor`] that uses the system's default beets /// executable. fn default() -> Self { - BeetsLibraryProcessExecutor::new("beet") + BeetsLibraryProcessExecutor::new() } } @@ -236,16 +257,75 @@ impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor { cmd.arg(path); } let output = cmd.args(arguments.iter().map(|s| s.as_ref())).output()?; - if !output.status.success() { - return Err(Error::CmdExec( - String::from_utf8_lossy(&output.stderr).to_string(), - )); - } - let output = str::from_utf8(&output.stdout)?; - Ok(output.split('\n').map(|s| s.to_string()).collect()) + Self::output(output) } } +impl BeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {} + +// GRCOV_EXCL_START +/// Beets library executor that executes beets commands over SSH. +pub struct BeetsLibrarySshExecutor { + rt: Runtime, + session: Session, + bin: String, + config: Option, +} + +impl From for Error { + fn from(err: openssh::Error) -> Error { + Error::Executor(err.to_string()) + } +} + +impl BeetsLibrarySshExecutor { + /// Create a new [`BeetsLibrarySshExecutor`] that uses the default beets executable over an SSH + /// connection. This call will attempt to establish the connection and will fail if the + /// connection fails. + pub fn new>(host: H) -> Result { + Self::bin(host, BEET_DEFAULT) + } + + /// Create a new [`BeetsLibrarySshExecutor`] that uses the provided beets executable over an SSH + /// connection. This call will attempt to establish the connection and will fail if the + /// connection fails. + pub fn bin, S: Into>(host: H, bin: S) -> Result { + let rt = runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let session = rt.block_on(Session::connect_mux(host, KnownHosts::Strict))?; + Ok(BeetsLibrarySshExecutor { + rt, + session, + bin: bin.into(), + config: None, + }) + } + + /// Update the configuration file passed to the beets executable. + pub fn config>(mut self, path: Option

) -> Self { + self.config = path.map(|p| p.into()); + self + } +} + +impl BeetsLibraryExecutor for BeetsLibrarySshExecutor { + fn exec + 'static>(&mut self, arguments: &[S]) -> Result, Error> { + let mut cmd = self.session.command(&self.bin); + if let Some(ref path) = self.config { + cmd.arg("--config"); + cmd.arg(path); + } + cmd.args(arguments.iter().map(|s| s.as_ref())); + let output = self.rt.block_on(cmd.output())?; + Self::output(output) + } +} + +impl BeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {} +// GRCOV_EXCL_STOP + #[cfg(test)] mod tests { use mockall::predicate; diff --git a/src/library/mod.rs b/src/library/mod.rs index 855c637..07f9592 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -60,26 +60,26 @@ impl Query { /// Error type for library calls. #[derive(Debug, PartialEq, Eq)] pub enum Error { - /// The underlying library failed to execute a command. - CmdExec(String), - /// The underlying library returned invalid data. - Invalid(String), - /// The underlying library experienced an I/O error. + /// The library's executor failed. + Executor(String), + /// The library experienced an I/O error. Io(String), - /// The underlying library failed to parse an integer. + /// The library received invalid data. + Invalid(String), + /// The library failed to parse an integer. ParseInt(String), - /// The underlying library failed to parse a UTF-8 string. + /// The library failed to parse a UTF-8 string. Utf8(String), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { - Self::CmdExec(ref s) => write!(f, "the library failed to execute a command: {s}"), - Self::Invalid(ref s) => write!(f, "the library received invalid data: {s}"), + Self::Executor(ref s) => write!(f, "the library's executor failed: {s}"), Self::Io(ref s) => write!(f, "the library experienced an I/O error: {s}"), - Self::ParseInt(ref s) => write!(f, "the library received an invalid integer: {s}"), - Self::Utf8(ref s) => write!(f, "the library received invalid UTF-8: {s}"), + Self::Invalid(ref s) => write!(f, "the library received invalid data: {s}"), + Self::ParseInt(ref s) => write!(f, "the library failed to parse an integer: {s}"), + Self::Utf8(ref s) => write!(f, "the library failed to parse a UTF-8 string: {s}"), } } } @@ -133,21 +133,21 @@ mod tests { #[test] fn errors() { - let cmd_err = Error::CmdExec(String::from("CmdExec")); - let inv_err = Error::Invalid(String::from("Invalid")); + let exe_err = Error::Executor(String::from("Executor")); let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "Interrupted").into(); + let inv_err = Error::Invalid(String::from("Invalid")); let int_err: Error = "five".parse::().unwrap_err().into(); let utf_err: Error = std::str::from_utf8(b"\xe2\x28\xa1").unwrap_err().into(); - assert!(!cmd_err.to_string().is_empty()); - assert!(!inv_err.to_string().is_empty()); + assert!(!exe_err.to_string().is_empty()); assert!(!io_err.to_string().is_empty()); + assert!(!inv_err.to_string().is_empty()); assert!(!int_err.to_string().is_empty()); assert!(!utf_err.to_string().is_empty()); - assert!(!format!("{:?}", cmd_err).is_empty()); - assert!(!format!("{:?}", inv_err).is_empty()); + assert!(!format!("{:?}", exe_err).is_empty()); assert!(!format!("{:?}", io_err).is_empty()); + assert!(!format!("{:?}", inv_err).is_empty()); assert!(!format!("{:?}", int_err).is_empty()); assert!(!format!("{:?}", utf_err).is_empty()); } diff --git a/src/main.rs b/src/main.rs index 957f9c8..04ad707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ +use musichoard::database::Database; +use musichoard::library::beets::BeetsLibrarySshExecutor; +use musichoard::library::Library; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use std::io; use std::path::PathBuf; +use std::{ffi::OsString, io}; use structopt::StructOpt; @@ -19,35 +22,22 @@ use tui::{ #[derive(StructOpt)] struct Opt { - #[structopt( - short = "b", - long = "beets", - name = "beets config file path", - parse(from_os_str) - )] - beets_config_file_path: Option, + #[structopt(long = "ssh", name = "beets SSH URI")] + beets_ssh_uri: Option, + + #[structopt(long = "beets", name = "beets config file path")] + beets_config_file_path: Option, #[structopt( - short = "d", long = "database", name = "database file path", - default_value = "database.json", - parse(from_os_str) + default_value = "database.json" )] database_file_path: PathBuf, } -fn main() { - // Create the application. - let opt = Opt::from_args(); - - let beets = BeetsLibrary::new( - BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path.as_deref()), - ); - - let database = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path)); - - let collection_manager = MhCollectionManager::new(beets, database); +fn with(lib: LIB, db: DB) { + let collection_manager = MhCollectionManager::new(lib, db); // Initialize the terminal user interface. let backend = CrosstermBackend::new(io::stdout()); @@ -65,6 +55,22 @@ fn main() { Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui"); } +fn main() { + // Create the application. + let opt = Opt::from_args(); + + if let Some(uri) = opt.beets_ssh_uri { + let uri = uri.into_string().expect("invalid SSH URI"); + let lib_exec = BeetsLibrarySshExecutor::new(uri).expect("failed to initialise beets"); + let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path); + with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec)); + } else { + let lib_exec = BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path); + let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path); + with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec)); + } +} + #[cfg(test)] #[macro_use] mod testlib;