From 1d09095ab90e30d8d997fe5e3146e3ef76225fb3 Mon Sep 17 00:00:00 2001 From: veeso Date: Thu, 26 Aug 2021 11:24:13 +0200 Subject: [PATCH] Aws s3 support --- .github/actions-rs/grcov.yml | 1 + CHANGELOG.md | 6 + Cargo.lock | 270 ++++++- Cargo.toml | 6 +- README.md | 4 +- docs/man.md | 40 + src/config/bookmarks.rs | 250 ++++++- src/config/serialization.rs | 113 ++- src/filetransfer/mod.rs | 127 +++- src/filetransfer/params.rs | 194 +++-- .../{ftp_transfer.rs => transfer/ftp.rs} | 113 +-- src/filetransfer/transfer/mod.rs | 18 + src/filetransfer/transfer/s3/mod.rs | 697 ++++++++++++++++++ src/filetransfer/transfer/s3/object.rs | 247 +++++++ .../{scp_transfer.rs => transfer/scp.rs} | 122 ++- .../{sftp_transfer.rs => transfer/sftp.rs} | 149 ++-- src/host/mod.rs | 4 +- src/lib.rs | 1 + src/main.rs | 48 +- src/system/bookmarks_client.rs | 373 ++++++---- src/ui/activities/auth/bookmarks.rs | 114 +-- src/ui/activities/auth/misc.rs | 74 +- src/ui/activities/auth/mod.rs | 22 + src/ui/activities/auth/update.rs | 67 +- src/ui/activities/auth/view.rs | 243 ++++-- .../activities/filetransfer/actions/copy.rs | 22 +- .../filetransfer/actions/newfile.rs | 34 +- .../activities/filetransfer/actions/rename.rs | 41 ++ .../activities/filetransfer/lib/transfer.rs | 9 + src/ui/activities/filetransfer/misc.rs | 12 + src/ui/activities/filetransfer/mod.rs | 38 +- src/ui/activities/filetransfer/session.rs | 549 ++++++++------ src/ui/activities/filetransfer/update.rs | 6 +- src/ui/activities/setup/view/setup.rs | 9 +- src/utils/parser.rs | 317 ++++++-- src/utils/path.rs | 82 ++- src/utils/test_helpers.rs | 9 +- 37 files changed, 3458 insertions(+), 973 deletions(-) rename src/filetransfer/{ftp_transfer.rs => transfer/ftp.rs} (92%) create mode 100644 src/filetransfer/transfer/mod.rs create mode 100644 src/filetransfer/transfer/s3/mod.rs create mode 100644 src/filetransfer/transfer/s3/object.rs rename src/filetransfer/{scp_transfer.rs => transfer/scp.rs} (95%) rename src/filetransfer/{sftp_transfer.rs => transfer/sftp.rs} (91%) diff --git a/.github/actions-rs/grcov.yml b/.github/actions-rs/grcov.yml index cf68823..be98493 100644 --- a/.github/actions-rs/grcov.yml +++ b/.github/actions-rs/grcov.yml @@ -9,6 +9,7 @@ ignore: - src/main.rs - src/lib.rs - src/activity_manager.rs + - src/filetransfer/transfer/s3/mod.rs - src/support.rs - "src/ui/activities/*" - src/ui/context.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ba442..674c6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Released on ?? > 🍁 Autumn update 🍇 +- **Aws S3** + - Added support for the aws-s3 protocol + - Operate on your bucket directly from the file explorer + - You can also save your buckets as bookmarks + - Aws s3 reads credentials directly from your credentials file at `$HOME/.aws/credentials` or from environment. Read more in the user manual. + ## 0.6.1 Released on 31/08/2021 diff --git a/Cargo.lock b/Cargo.lock index 65f5fd9..08919a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,12 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "ahash" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" + [[package]] name = "aho-corasick" version = "0.7.18" @@ -51,6 +57,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "anyhow" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28ae2b3dec75a406790005a200b1bd89785afc02517a00ca99ecfe093ee9e6cf" + [[package]] name = "argh" version = "0.1.5" @@ -80,12 +92,78 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a61eb019cb8f415d162cb9f12130ee6bbe9168b7d953c17f4ad049e4051ca00" +[[package]] +name = "async-trait" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44318e776df68115a881de9a8fd1b9e53368d7a4a5ce4cc48517da3393233a5e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attohttpc" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb8867f378f33f78a811a8eb9bf108ad99430d7aad43315dd9319c827ef6247" +dependencies = [ + "http", + "log", + "native-tls", + "openssl", + "serde", + "serde_json", + "url", + "wildmatch 1.1.0", +] + +[[package]] +name = "attohttpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8bda305457262b339322106c776e3fd21df860018e566eb6a5b1aa4b6ae02d" +dependencies = [ + "http", + "log", + "native-tls", + "openssl", + "url", + "wildmatch 1.1.0", +] + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "aws-creds" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1331d069460a674d42bd27c12b47ce578f789954c7bd7f239fd030771eca6616" +dependencies = [ + "anyhow", + "attohttpc 0.16.3", + "dirs", + "rust-ini", + "serde", + "serde-xml-rs", + "serde_derive", + "url", +] + +[[package]] +name = "aws-region" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2884b8f2aaeb4a4bf80b219b4fe1d340139ca9331679c57e0fd4a24f571a78bd" +dependencies = [ + "anyhow", +] + [[package]] name = "base64" version = "0.13.0" @@ -136,6 +214,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + [[package]] name = "bytesize" version = "1.1.0" @@ -368,6 +452,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "dlv-list" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68df3f2b690c1b86e65ef7830956aededf3cb0a16f898f79b9a6f421a7b6211b" +dependencies = [ + "rand 0.8.4", +] + [[package]] name = "edit" version = "0.1.3" @@ -384,6 +477,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -409,6 +508,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-core" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af51b1b4a7fdff033703db39de8802c673eb91855f2e0d47dcf3bf2c0ef01f99" + [[package]] name = "generic-array" version = "0.14.4" @@ -441,6 +546,15 @@ dependencies = [ "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +dependencies = [ + "ahash", +] + [[package]] name = "heck" version = "0.3.3" @@ -450,6 +564,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.10.0" @@ -481,6 +601,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "http" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "idna" version = "0.2.3" @@ -622,6 +753,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "maybe-async" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6007f9dad048e0a224f27ca599d669fca8cfa0dac804725aab542b2eb032bce6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "md-5" version = "0.9.1" @@ -633,12 +775,27 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "minidom" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332592c2149fc7dd40a64fc9ef6f0d65607284b474cef9817d1fc8c7e7b3608e" +dependencies = [ + "quick-xml", +] + [[package]] name = "mio" version = "0.7.13" @@ -819,6 +976,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-multimap" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c672c7ad9ec066e428c00eb917124a06f08db19e2584de982cc34b1f4c12485" +dependencies = [ + "dlv-list", + "hashbrown", +] + [[package]] name = "output_vt100" version = "0.1.2" @@ -895,6 +1062,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pin-project-lite" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" + [[package]] name = "pkg-config" version = "0.3.19" @@ -928,6 +1101,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "quick-xml" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.9" @@ -1094,6 +1276,46 @@ dependencies = [ "winapi", ] +[[package]] +name = "rust-ini" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b134767a87e0b086f73a4ce569ac9ce7d202f39c8eab6caa266e2617e73ac6" +dependencies = [ + "cfg-if 0.1.10", + "ordered-multimap", +] + +[[package]] +name = "rust-s3" +version = "0.27.0-rc4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c93272c1d654d492f8ab30b94cd43d98f2700b1db55b2576aff7712ce40e3ef" +dependencies = [ + "anyhow", + "async-trait", + "attohttpc 0.17.0", + "aws-creds", + "aws-region", + "base64", + "cfg-if 1.0.0", + "chrono", + "hex", + "hmac", + "http", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "serde", + "serde-xml-rs", + "serde_derive", + "sha2", + "tokio-stream", + "url", +] + [[package]] name = "rustls" version = "0.19.1" @@ -1210,6 +1432,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-xml-rs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bf1ba0696ccf0872866277143ff1fd14d22eec235d2b23702f95e6660f7dfa" +dependencies = [ + "log", + "serde", + "thiserror", + "xml-rs", +] + [[package]] name = "serde_derive" version = "1.0.127" @@ -1392,6 +1626,7 @@ dependencies = [ "rand 0.8.4", "regex", "rpassword", + "rust-s3", "serde", "simplelog", "ssh2", @@ -1405,7 +1640,7 @@ dependencies = [ "ureq", "users", "whoami", - "wildmatch", + "wildmatch 2.1.0", ] [[package]] @@ -1476,6 +1711,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" +[[package]] +name = "tokio" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92036be488bb6594459f2e03b60e42df6f937fe6ca5c5ffdcb539c6b84dc40f5" +dependencies = [ + "autocfg", + "pin-project-lite", +] + +[[package]] +name = "tokio-stream" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2f3f698253f03119ac0102beaa64f67a67e08074d03a22d18784104543727f" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.8" @@ -1741,6 +1997,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wildmatch" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f44b95f62d34113cf558c93511ac93027e03e9c29a60dd0fd70e6e025c7270a" + [[package]] name = "wildmatch" version = "2.1.0" @@ -1777,3 +2039,9 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "xml-rs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" diff --git a/Cargo.toml b/Cargo.toml index 7c55e8f..0b88c4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Christian Visintin"] categories = ["command-line-utilities"] -description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP" +description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/S3" documentation = "https://docs.rs/termscp" edition = "2018" homepage = "https://veeso.github.io/termscp/" @@ -44,6 +44,7 @@ open = "2.0.1" rand = "0.8.4" regex = "1.5.4" rpassword = "5.0.1" +rust-s3 = { version = "0.27.0-rc4", default-features = false, features = [ "sync-native-tls", "sync" ] } serde = { version = "^1.0.0", features = [ "derive" ] } simplelog = "0.10.0" ssh2 = "0.9.0" @@ -63,7 +64,8 @@ pretty_assertions = "0.7.2" [features] default = [ "with-keyring" ] -github-actions = [] +github-actions = [ ] +with-s3-ci = [] with-containers = [] with-keyring = [ "keyring" ] diff --git a/README.md b/README.md index ba51645..96a4b17 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ## About termscp 🖥 -Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **BSD** and **Windows** compatible and supports SFTP, SCP, FTP and FTPS. +Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/S3. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **BSD** and **Windows** compatible and supports SFTP, SCP, FTP, FTPS and S3. ![Explorer](assets/images/explorer.gif) @@ -36,6 +36,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for - SFTP - SCP - FTP and FTPS + - Aws S3 - 🖥 Explore and operate on the remote and on the local machine file system with a handy UI - Create, remove, rename, search, view and edit files - ⭐ Connect to your favourite hosts through built-in bookmarks and recent connections @@ -160,6 +161,7 @@ termscp is powered by these aweseome projects: - [keyring-rs](https://github.com/hwchen/keyring-rs) - [open-rs](https://github.com/Byron/open-rs) - [rpassword](https://github.com/conradkleinespel/rpassword) +- [rust-s3](https://github.com/durch/rust-s3) - [ssh2-rs](https://github.com/alexcrichton/ssh2-rs) - [suppaftp](https://github.com/veeso/suppaftp) - [textwrap](https://github.com/mgeisler/textwrap) diff --git a/docs/man.md b/docs/man.md index 0b1df9d..d635fae 100644 --- a/docs/man.md +++ b/docs/man.md @@ -3,6 +3,7 @@ - [User manual 🎓](#user-manual-) - [Usage ❓](#usage-) - [Address argument 🌎](#address-argument-) + - [AWS S3 address argument](#aws-s3-address-argument) - [How Password can be provided 🔐](#how-password-can-be-provided-) - [File explorer 📂](#file-explorer-) - [Keybindings ⌨](#keybindings-) @@ -13,6 +14,7 @@ - [Are my passwords Safe 😈](#are-my-passwords-safe-) - [Linux Keyring](#linux-keyring) - [KeepassXC setup for termscp](#keepassxc-setup-for-termscp) + - [Aws S3 credentials 🦊](#aws-s3-credentials-) - [Configuration ⚙️](#configuration-️) - [SSH Key Storage 🔐](#ssh-key-storage-) - [File Explorer Format](#file-explorer-format) @@ -78,6 +80,20 @@ Let's see some example of this particular syntax, since it's very comfortable an termscp scp://omar@192.168.1.31:4022:/tmp ``` +#### AWS S3 address argument + +Aws S3 has a different syntax for CLI address argument, for obvious reasons, but I managed to keep it the more similiar as possible to the generic address argument: + +```txt +s3://@[:profile][:/wrkdir] +``` + +e.g. + +```txt +s3://buckethead@eu-central-1:default:/assets +``` + #### How Password can be provided 🔐 You have probably noticed, that, when providing the address as argument, there's no way to provide the password. @@ -246,6 +262,30 @@ Follow these steps in order to setup keepassXC for termscp: --- +## Aws S3 credentials 🦊 + +In order to connect to an Aws S3 bucket you must obviously provide some credentials. +There are basically two ways to achieve this, and as you've probably already noticed you **can't** do that via the authentication form. +So these are the ways you can provide the credentials for s3: + +1. Use your credentials file: just configure the AWS cli via `aws configure` and your credentials should already be located at `~/.aws/credentials`. In case you're using a profile different from `default`, just provide it in the profile field in the authentication form. +2. **Environment variables**: you can always provide your credentials as environment variables. Keep in mind that these credentials **will always override** the credentials located in the `credentials` file. See how to configure the environment below: + + These should always be mandatory: + + - `AWS_ACCESS_KEY_ID`: aws access key ID (usually starts with `AKIA...`) + - `AWS_SECRET_ACCESS_KEY`: the secret access key + + In case you've configured a stronger security, you *may* require these too: + + - `AWS_SECURITY_TOKEN`: security token + - `AWS_SESSION_TOKEN`: session token + +⚠️ Your credentials are safe: termscp won't manipulate these values directly! Your credentials are directly consumed by the **s3** crate. +In case you've got some concern regarding security, please contact the library author on [Github](https://github.com/durch/rust-s3) ⚠️ + +--- + ## Configuration ⚙️ termscp supports some user defined parameters, which can be defined in the configuration. diff --git a/src/config/bookmarks.rs b/src/config/bookmarks.rs index 0bb5265..dd7fcdf 100644 --- a/src/config/bookmarks.rs +++ b/src/config/bookmarks.rs @@ -25,31 +25,57 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams, ProtocolParams}; +use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; + +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashMap; +use std::str::FromStr; -#[derive(Deserialize, Serialize, std::fmt::Debug)] /// ## UserHosts /// /// UserHosts contains all the hosts saved by the user in the data storage /// It contains both `Bookmark` +#[derive(Deserialize, Serialize, Debug)] pub struct UserHosts { pub bookmarks: HashMap, pub recents: HashMap, } -#[derive(Deserialize, Serialize, std::fmt::Debug, PartialEq)] /// ## Bookmark /// /// Bookmark describes a single bookmark entry in the user hosts storage +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)] pub struct Bookmark { - pub address: String, - pub port: u16, - pub protocol: String, - pub username: String, - pub password: Option, // Password is optional; base64, aes-128 encrypted password + #[serde( + deserialize_with = "deserialize_protocol", + serialize_with = "serialize_protocol" + )] + pub protocol: FileTransferProtocol, + /// Address for generic parameters + pub address: Option, + /// Port number for generic parameters + pub port: Option, + /// Username for generic parameters + pub username: Option, + /// Password is optional; base64, aes-128 encrypted password + pub password: Option, + /// S3 params; optional. When used other fields are empty for sure + pub s3: Option, } +/// ## S3Params +/// +/// Connection parameters for Aws s3 protocol +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Default)] +pub struct S3Params { + pub bucket: String, + pub region: String, + pub profile: Option, +} + +// -- impls + impl Default for UserHosts { fn default() -> Self { Self { @@ -59,6 +85,87 @@ impl Default for UserHosts { } } +impl From for Bookmark { + fn from(params: FileTransferParams) -> Self { + let protocol: FileTransferProtocol = params.protocol; + // Create generic or others + match params.params { + ProtocolParams::Generic(params) => Self { + protocol, + address: Some(params.address), + port: Some(params.port), + username: params.username, + password: params.password, + s3: None, + }, + ProtocolParams::AwsS3(params) => Self { + protocol, + address: None, + port: None, + username: None, + password: None, + s3: Some(S3Params::from(params)), + }, + } + } +} + +impl From for FileTransferParams { + fn from(bookmark: Bookmark) -> Self { + // Create generic or others based on protocol + match bookmark.protocol { + FileTransferProtocol::AwsS3 => { + let params = bookmark.s3.unwrap_or_default(); + let params = AwsS3Params::from(params); + Self::new(FileTransferProtocol::AwsS3, ProtocolParams::AwsS3(params)) + } + protocol => { + let params = GenericProtocolParams::default() + .address(bookmark.address.unwrap_or_default()) + .port(bookmark.port.unwrap_or(22)) + .username(bookmark.username) + .password(bookmark.password); + Self::new(protocol, ProtocolParams::Generic(params)) + } + } + } +} + +impl From for S3Params { + fn from(params: AwsS3Params) -> Self { + S3Params { + bucket: params.bucket_name, + region: params.region, + profile: params.profile, + } + } +} + +impl From for AwsS3Params { + fn from(params: S3Params) -> Self { + AwsS3Params::new(params.bucket, params.region, params.profile) + } +} + +fn deserialize_protocol<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: &str = Deserialize::deserialize(deserializer)?; + // Parse color + match FileTransferProtocol::from_str(s) { + Err(err) => Err(DeError::custom(err)), + Ok(protocol) => Ok(protocol), + } +} + +fn serialize_protocol(protocol: &FileTransferProtocol, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(protocol.to_string().as_str()) +} + // Tests #[cfg(test)] @@ -77,48 +184,117 @@ mod tests { #[test] fn test_bookmarks_bookmark_new() { let bookmark: Bookmark = Bookmark { - address: String::from("192.168.1.1"), - port: 22, - protocol: String::from("SFTP"), - username: String::from("root"), + address: Some(String::from("192.168.1.1")), + port: Some(22), + protocol: FileTransferProtocol::Sftp, + username: Some(String::from("root")), password: Some(String::from("password")), + s3: None, }; let recent: Bookmark = Bookmark { - address: String::from("192.168.1.2"), - port: 22, - protocol: String::from("SCP"), - username: String::from("admin"), + address: Some(String::from("192.168.1.2")), + port: Some(22), + protocol: FileTransferProtocol::Scp, + username: Some(String::from("admin")), password: Some(String::from("password")), + s3: None, }; let mut bookmarks: HashMap = HashMap::with_capacity(1); bookmarks.insert(String::from("test"), bookmark); let mut recents: HashMap = HashMap::with_capacity(1); recents.insert(String::from("ISO20201218T181432"), recent); - let hosts: UserHosts = UserHosts { - bookmarks: bookmarks, - recents: recents, - }; + let hosts: UserHosts = UserHosts { bookmarks, recents }; // Verify let bookmark: &Bookmark = hosts.bookmarks.get(&String::from("test")).unwrap(); - assert_eq!(bookmark.address, String::from("192.168.1.1")); - assert_eq!(bookmark.port, 22); - assert_eq!(bookmark.protocol, String::from("SFTP")); - assert_eq!(bookmark.username, String::from("root")); - assert_eq!( - *bookmark.password.as_ref().unwrap(), - String::from("password") - ); + assert_eq!(bookmark.address.as_deref().unwrap(), "192.168.1.1"); + assert_eq!(bookmark.port.unwrap(), 22); + assert_eq!(bookmark.protocol, FileTransferProtocol::Sftp); + assert_eq!(bookmark.username.as_deref().unwrap(), "root"); + assert_eq!(bookmark.password.as_deref().unwrap(), "password"); let bookmark: &Bookmark = hosts .recents .get(&String::from("ISO20201218T181432")) .unwrap(); - assert_eq!(bookmark.address, String::from("192.168.1.2")); - assert_eq!(bookmark.port, 22); - assert_eq!(bookmark.protocol, String::from("SCP")); - assert_eq!(bookmark.username, String::from("admin")); - assert_eq!( - *bookmark.password.as_ref().unwrap(), - String::from("password") - ); + assert_eq!(bookmark.address.as_deref().unwrap(), "192.168.1.2"); + assert_eq!(bookmark.port.unwrap(), 22); + assert_eq!(bookmark.protocol, FileTransferProtocol::Scp); + assert_eq!(bookmark.username.as_deref().unwrap(), "admin"); + assert_eq!(bookmark.password.as_deref().unwrap(), "password"); + } + + #[test] + fn bookmark_from_generic_ftparams() { + let params = ProtocolParams::Generic(GenericProtocolParams { + address: "127.0.0.1".to_string(), + port: 10222, + username: Some(String::from("root")), + password: Some(String::from("omar")), + }); + let params: FileTransferParams = FileTransferParams::new(FileTransferProtocol::Scp, params); + let bookmark = Bookmark::from(params); + assert_eq!(bookmark.protocol, FileTransferProtocol::Scp); + assert_eq!(bookmark.address.as_deref().unwrap(), "127.0.0.1"); + assert_eq!(bookmark.port.unwrap(), 10222); + assert_eq!(bookmark.username.as_deref().unwrap(), "root"); + assert_eq!(bookmark.password.as_deref().unwrap(), "omar"); + assert!(bookmark.s3.is_none()); + } + + #[test] + fn bookmark_from_s3_ftparams() { + let params = ProtocolParams::AwsS3(AwsS3Params::new("omar", "eu-west-1", Some("test"))); + let params: FileTransferParams = + FileTransferParams::new(FileTransferProtocol::AwsS3, params); + let bookmark = Bookmark::from(params); + assert_eq!(bookmark.protocol, FileTransferProtocol::AwsS3); + assert!(bookmark.address.is_none()); + assert!(bookmark.port.is_none()); + assert!(bookmark.username.is_none()); + assert!(bookmark.password.is_none()); + let s3: &S3Params = bookmark.s3.as_ref().unwrap(); + assert_eq!(s3.bucket.as_str(), "omar"); + assert_eq!(s3.region.as_str(), "eu-west-1"); + assert_eq!(s3.profile.as_deref().unwrap(), "test"); + } + + #[test] + fn ftparams_from_generic_bookmark() { + let bookmark: Bookmark = Bookmark { + address: Some(String::from("192.168.1.1")), + port: Some(22), + protocol: FileTransferProtocol::Sftp, + username: Some(String::from("root")), + password: Some(String::from("password")), + s3: None, + }; + let params = FileTransferParams::from(bookmark); + assert_eq!(params.protocol, FileTransferProtocol::Sftp); + let gparams = params.params.generic_params().unwrap(); + assert_eq!(gparams.address.as_str(), "192.168.1.1"); + assert_eq!(gparams.port, 22); + assert_eq!(gparams.username.as_deref().unwrap(), "root"); + assert_eq!(gparams.password.as_deref().unwrap(), "password"); + } + + #[test] + fn ftparams_from_s3_bookmark() { + let bookmark: Bookmark = Bookmark { + protocol: FileTransferProtocol::AwsS3, + address: None, + port: None, + username: None, + password: None, + s3: Some(S3Params { + bucket: String::from("veeso"), + region: String::from("eu-west-1"), + profile: None, + }), + }; + let params = FileTransferParams::from(bookmark); + assert_eq!(params.protocol, FileTransferProtocol::AwsS3); + let gparams = params.params.s3_params().unwrap(); + assert_eq!(gparams.bucket_name.as_str(), "veeso"); + assert_eq!(gparams.region.as_str(), "eu-west-1"); + assert_eq!(gparams.profile, None); } } diff --git a/src/config/serialization.rs b/src/config/serialization.rs index eacd88f..6505c3a 100644 --- a/src/config/serialization.rs +++ b/src/config/serialization.rs @@ -141,17 +141,19 @@ where mod tests { use super::*; + + use crate::config::bookmarks::{Bookmark, S3Params, UserHosts}; + use crate::config::params::UserConfig; + use crate::config::themes::Theme; + use crate::filetransfer::FileTransferProtocol; + use crate::utils::test_helpers::create_file_ioers; + use pretty_assertions::assert_eq; use std::collections::HashMap; use std::io::{Seek, SeekFrom}; use std::path::PathBuf; use tuirealm::tui::style::Color; - use crate::config::bookmarks::{Bookmark, UserHosts}; - use crate::config::params::UserConfig; - use crate::config::themes::Theme; - use crate::utils::test_helpers::create_file_ioers; - #[test] fn test_config_serialization_errors() { let error: SerializerError = SerializerError::new(SerializerErrorKind::Syntax); @@ -373,31 +375,42 @@ mod tests { // Verify recents assert_eq!(hosts.recents.len(), 1); let host: &Bookmark = hosts.recents.get("ISO20201215T094000Z").unwrap(); - assert_eq!(host.address, String::from("172.16.104.10")); - assert_eq!(host.port, 22); - assert_eq!(host.protocol, String::from("SCP")); - assert_eq!(host.username, String::from("root")); + assert_eq!(host.address.as_deref().unwrap(), "172.16.104.10"); + assert_eq!(host.port.unwrap(), 22); + assert_eq!(host.protocol, FileTransferProtocol::Scp); + assert_eq!(host.username.as_deref().unwrap(), "root"); assert_eq!(host.password, None); // Verify bookmarks - assert_eq!(hosts.bookmarks.len(), 3); + assert_eq!(hosts.bookmarks.len(), 4); let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap(); - assert_eq!(host.address, String::from("192.168.1.31")); - assert_eq!(host.port, 22); - assert_eq!(host.protocol, String::from("SFTP")); - assert_eq!(host.username, String::from("root")); - assert_eq!(*host.password.as_ref().unwrap(), String::from("mypassword")); + assert_eq!(host.address.as_deref().unwrap(), "192.168.1.31"); + assert_eq!(host.port.unwrap(), 22); + assert_eq!(host.protocol, FileTransferProtocol::Sftp); + assert_eq!(host.username.as_deref().unwrap(), "root"); + assert_eq!(host.password.as_deref().unwrap(), "mypassword"); let host: &Bookmark = hosts.bookmarks.get("msi-estrem").unwrap(); - assert_eq!(host.address, String::from("192.168.1.30")); - assert_eq!(host.port, 22); - assert_eq!(host.protocol, String::from("SFTP")); - assert_eq!(host.username, String::from("cvisintin")); - assert_eq!(*host.password.as_ref().unwrap(), String::from("mysecret")); + assert_eq!(host.address.as_deref().unwrap(), "192.168.1.30"); + assert_eq!(host.port.unwrap(), 22); + assert_eq!(host.protocol, FileTransferProtocol::Sftp); + assert_eq!(host.username.as_deref().unwrap(), "cvisintin"); + assert_eq!(host.password.as_deref().unwrap(), "mysecret"); let host: &Bookmark = hosts.bookmarks.get("aws-server-prod1").unwrap(); - assert_eq!(host.address, String::from("51.23.67.12")); - assert_eq!(host.port, 21); - assert_eq!(host.protocol, String::from("FTPS")); - assert_eq!(host.username, String::from("aws001")); + assert_eq!(host.address.as_deref().unwrap(), "51.23.67.12"); + assert_eq!(host.port.unwrap(), 21); + assert_eq!(host.protocol, FileTransferProtocol::Ftp(true)); + assert_eq!(host.username.as_deref().unwrap(), "aws001"); assert_eq!(host.password, None); + // Aws s3 bucket + let host: &Bookmark = hosts.bookmarks.get("my-bucket").unwrap(); + assert_eq!(host.address, None); + assert_eq!(host.port, None); + assert_eq!(host.username, None); + assert_eq!(host.password, None); + assert_eq!(host.protocol, FileTransferProtocol::AwsS3); + let s3 = host.s3.as_ref().unwrap(); + assert_eq!(s3.bucket.as_str(), "veeso"); + assert_eq!(s3.region.as_str(), "eu-west-1"); + assert_eq!(s3.profile.as_deref().unwrap(), "default"); } #[test] @@ -416,32 +429,50 @@ mod tests { bookmarks.insert( String::from("raspberrypi2"), Bookmark { - address: String::from("192.168.1.31"), - port: 22, - protocol: String::from("SFTP"), - username: String::from("root"), + address: Some(String::from("192.168.1.31")), + port: Some(22), + protocol: FileTransferProtocol::Sftp, + username: Some(String::from("root")), password: None, + s3: None, }, ); bookmarks.insert( String::from("msi-estrem"), Bookmark { - address: String::from("192.168.1.30"), - port: 4022, - protocol: String::from("SFTP"), - username: String::from("cvisintin"), + address: Some(String::from("192.168.1.30")), + port: Some(4022), + protocol: FileTransferProtocol::Sftp, + username: Some(String::from("cvisintin")), password: Some(String::from("password")), + s3: None, + }, + ); + bookmarks.insert( + String::from("my-bucket"), + Bookmark { + address: None, + port: None, + protocol: FileTransferProtocol::AwsS3, + username: None, + password: None, + s3: Some(S3Params { + bucket: "veeso".to_string(), + region: "eu-west-1".to_string(), + profile: None, + }), }, ); let mut recents: HashMap = HashMap::with_capacity(1); recents.insert( String::from("ISO20201215T094000Z"), Bookmark { - address: String::from("192.168.1.254"), - port: 3022, - protocol: String::from("SCP"), - username: String::from("omar"), + address: Some(String::from("192.168.1.254")), + port: Some(3022), + protocol: FileTransferProtocol::Scp, + username: Some(String::from("omar")), password: Some(String::from("aaa")), + s3: None, }, ); let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); @@ -482,6 +513,14 @@ mod tests { raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root", password = "mypassword" } msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin", password = "mysecret" } aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" } + + [bookmarks.my-bucket] + protocol = "S3" + + [bookmarks.my-bucket.s3] + bucket = "veeso" + region = "eu-west-1" + profile = "default" [recents] ISO20201215T094000Z = { address = "172.16.104.10", port = 22, protocol = "SCP", username = "root" } @@ -497,7 +536,7 @@ mod tests { let file_content: &str = r#" [bookmarks] raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root"} - msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP" } + msi-estrem = { address = "192.168.1.30", port = 22 } aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" } [recents] diff --git a/src/filetransfer/mod.rs b/src/filetransfer/mod.rs index 39d10c0..4208890 100644 --- a/src/filetransfer/mod.rs +++ b/src/filetransfer/mod.rs @@ -28,27 +28,29 @@ // locals use crate::fs::{FsEntry, FsFile}; // ext -use std::io::{Read, Write}; +use std::fs::File; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use thiserror::Error; use wildmatch::WildMatch; // exports -pub mod ftp_transfer; pub mod params; -pub mod scp_transfer; -pub mod sftp_transfer; +mod transfer; -pub use params::FileTransferParams; +// -- export types +pub use params::{FileTransferParams, ProtocolParams}; +pub use transfer::{FtpFileTransfer, S3FileTransfer, ScpFileTransfer, SftpFileTransfer}; /// ## FileTransferProtocol /// /// This enum defines the different transfer protocol available in termscp -#[derive(PartialEq, Debug, std::clone::Clone, Copy)] +#[derive(PartialEq, Debug, Clone, Copy)] pub enum FileTransferProtocol { Sftp, Scp, Ftp(bool), // Bool is for secure (true => ftps) + AwsS3, } /// ## FileTransferError @@ -130,25 +132,16 @@ impl std::fmt::Display for FileTransferError { /// ## FileTransfer /// /// File transfer trait must be implemented by all the file transfers and defines the method used by a generic file transfer - pub trait FileTransfer { /// ### connect /// /// Connect to the remote server /// Can return banner / welcome message on success - - fn connect( - &mut self, - address: String, - port: u16, - username: Option, - password: Option, - ) -> Result, FileTransferError>; + fn connect(&mut self, params: &ProtocolParams) -> Result, FileTransferError>; /// ### disconnect /// /// Disconnect from the remote server - fn disconnect(&mut self) -> Result<(), FileTransferError>; /// ### is_connected @@ -210,18 +203,28 @@ pub trait FileTransfer { /// Send file to remote /// File name is referred to the name of the file as it will be saved /// Data contains the file data - /// Returns file and its size + /// Returns file and its size. + /// By default returns unsupported feature fn send_file( &mut self, - local: &FsFile, - file_name: &Path, - ) -> Result, FileTransferError>; + _local: &FsFile, + _file_name: &Path, + ) -> Result, FileTransferError> { + Err(FileTransferError::new( + FileTransferErrorType::UnsupportedFeature, + )) + } /// ### recv_file /// /// Receive file from remote with provided name /// Returns file and its size - fn recv_file(&mut self, file: &FsFile) -> Result, FileTransferError>; + /// By default returns unsupported feature + fn recv_file(&mut self, _file: &FsFile) -> Result, FileTransferError> { + Err(FileTransferError::new( + FileTransferErrorType::UnsupportedFeature, + )) + } /// ### on_sent /// @@ -230,7 +233,10 @@ pub trait FileTransfer { /// The purpose of this method is to finalize the connection with the peer when writing data. /// This is necessary for some protocols such as FTP. /// You must call this method each time you want to finalize the write of the remote file. - fn on_sent(&mut self, writable: Box) -> Result<(), FileTransferError>; + /// By default this function returns already `Ok(())` + fn on_sent(&mut self, _writable: Box) -> Result<(), FileTransferError> { + Ok(()) + } /// ### on_recv /// @@ -239,7 +245,71 @@ pub trait FileTransfer { /// The purpose of this method is to finalize the connection with the peer when reading data. /// This mighe be necessary for some protocols. /// You must call this method each time you want to finalize the read of the remote file. - fn on_recv(&mut self, readable: Box) -> Result<(), FileTransferError>; + /// By default this function returns already `Ok(())` + fn on_recv(&mut self, _readable: Box) -> Result<(), FileTransferError> { + Ok(()) + } + + /// ### send_file_wno_stream + /// + /// Send a file to remote WITHOUT using streams. + /// This method SHOULD be implemented ONLY when streams are not supported by the current file transfer. + /// The developer implementing the filetransfer user should FIRST try with `send_file` followed by `on_sent` + /// If the function returns error kind() `UnsupportedFeature`, then he should call this function. + /// By default this function uses the streams function to copy content from reader to writer + fn send_file_wno_stream( + &mut self, + src: &FsFile, + dest: &Path, + mut reader: Box, + ) -> Result<(), FileTransferError> { + match self.is_connected() { + true => { + let mut stream = self.send_file(src, dest)?; + io::copy(&mut reader, &mut stream).map_err(|e| { + FileTransferError::new_ex(FileTransferErrorType::ProtocolError, e.to_string()) + })?; + self.on_sent(stream) + } + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### recv_file_wno_stream + /// + /// Receive a file from remote WITHOUT using streams. + /// This method SHOULD be implemented ONLY when streams are not supported by the current file transfer. + /// The developer implementing the filetransfer user should FIRST try with `send_file` followed by `on_sent` + /// If the function returns error kind() `UnsupportedFeature`, then he should call this function. + /// For safety reasons this function doesn't accept the `Write` trait, but the destination path. + /// By default this function uses the streams function to copy content from reader to writer + fn recv_file_wno_stream(&mut self, src: &FsFile, dest: &Path) -> Result<(), FileTransferError> { + match self.is_connected() { + true => { + let mut writer = File::create(dest).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::FileCreateDenied, + format!("Could not open local file: {}", e), + ) + })?; + let mut stream = self.recv_file(src)?; + io::copy(&mut stream, &mut writer) + .map(|_| ()) + .map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::ProtocolError, + e.to_string(), + ) + })?; + self.on_recv(stream) + } + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } /// ### find /// @@ -314,6 +384,7 @@ impl std::string::ToString for FileTransferProtocol { }, FileTransferProtocol::Scp => "SCP", FileTransferProtocol::Sftp => "SFTP", + FileTransferProtocol::AwsS3 => "S3", }) } } @@ -326,6 +397,7 @@ impl std::str::FromStr for FileTransferProtocol { "FTPS" => Ok(FileTransferProtocol::Ftp(true)), "SCP" => Ok(FileTransferProtocol::Scp), "SFTP" => Ok(FileTransferProtocol::Sftp), + "S3" => Ok(FileTransferProtocol::AwsS3), _ => Err(s.to_string()), } } @@ -385,6 +457,14 @@ mod tests { FileTransferProtocol::from_str("scp").ok().unwrap(), FileTransferProtocol::Scp ); + assert_eq!( + FileTransferProtocol::from_str("S3").ok().unwrap(), + FileTransferProtocol::AwsS3 + ); + assert_eq!( + FileTransferProtocol::from_str("s3").ok().unwrap(), + FileTransferProtocol::AwsS3 + ); // Error assert!(FileTransferProtocol::from_str("dummy").is_err()); // To String @@ -398,6 +478,7 @@ mod tests { ); assert_eq!(FileTransferProtocol::Scp.to_string(), String::from("SCP")); assert_eq!(FileTransferProtocol::Sftp.to_string(), String::from("SFTP")); + assert_eq!(FileTransferProtocol::AwsS3.to_string(), String::from("S3")); } #[test] diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs index 893b172..c274ceb 100644 --- a/src/filetransfer/params.rs +++ b/src/filetransfer/params.rs @@ -32,44 +32,132 @@ use std::path::{Path, PathBuf}; /// ### FileTransferParams /// /// Holds connection parameters for file transfers -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct FileTransferParams { + pub protocol: FileTransferProtocol, + pub params: ProtocolParams, + pub entry_directory: Option, +} + +/// ## ProtocolParams +/// +/// Container for protocol params +#[derive(Debug, Clone)] +pub enum ProtocolParams { + Generic(GenericProtocolParams), + AwsS3(AwsS3Params), +} + +/// ## GenericProtocolParams +/// +/// Protocol params used by most common protocols +#[derive(Debug, Clone)] +pub struct GenericProtocolParams { pub address: String, pub port: u16, - pub protocol: FileTransferProtocol, pub username: Option, pub password: Option, - pub entry_directory: Option, +} + +/// ## AwsS3Params +/// +/// Connection parameters for AWS S3 protocol +#[derive(Debug, Clone)] +pub struct AwsS3Params { + pub bucket_name: String, + pub region: String, + pub profile: Option, } impl FileTransferParams { /// ### new /// /// Instantiates a new `FileTransferParams` - pub fn new>(address: S) -> Self { + pub fn new(protocol: FileTransferProtocol, params: ProtocolParams) -> Self { Self { - address: address.as_ref().to_string(), - port: 22, - protocol: FileTransferProtocol::Sftp, - username: None, - password: None, + protocol, + params, entry_directory: None, } } - /// ### port + /// ### entry_directory /// - /// Set port for params - pub fn port(mut self, port: u16) -> Self { - self.port = port; + /// Set entry directory + pub fn entry_directory>(mut self, dir: Option

) -> Self { + self.entry_directory = dir.map(|x| x.as_ref().to_path_buf()); + self + } +} + +impl Default for FileTransferParams { + fn default() -> Self { + Self::new(FileTransferProtocol::Sftp, ProtocolParams::default()) + } +} + +impl Default for ProtocolParams { + fn default() -> Self { + Self::Generic(GenericProtocolParams::default()) + } +} + +impl ProtocolParams { + /// ### generic_params + /// + /// Retrieve generic parameters from protocol params if any + pub fn generic_params(&self) -> Option<&GenericProtocolParams> { + match self { + ProtocolParams::Generic(params) => Some(params), + _ => None, + } + } + + pub fn mut_generic_params(&mut self) -> Option<&mut GenericProtocolParams> { + match self { + ProtocolParams::Generic(params) => Some(params), + _ => None, + } + } + + /// ### s3_params + /// + /// Retrieve AWS S3 parameters if any + pub fn s3_params(&self) -> Option<&AwsS3Params> { + match self { + ProtocolParams::AwsS3(params) => Some(params), + _ => None, + } + } +} + +// -- Generic protocol params + +impl Default for GenericProtocolParams { + fn default() -> Self { + Self { + address: "localhost".to_string(), + port: 22, + username: None, + password: None, + } + } +} + +impl GenericProtocolParams { + /// ### address + /// + /// Set address to params + pub fn address>(mut self, address: S) -> Self { + self.address = address.as_ref().to_string(); self } - /// ### protocol + /// ### port /// - /// Set protocol for params - pub fn protocol(mut self, protocol: FileTransferProtocol) -> Self { - self.protocol = protocol; + /// Set port to params + pub fn port(mut self, port: u16) -> Self { + self.port = port; self } @@ -88,19 +176,20 @@ impl FileTransferParams { self.password = password.map(|x| x.as_ref().to_string()); self } - - /// ### entry_directory - /// - /// Set entry directory - pub fn entry_directory>(mut self, dir: Option

) -> Self { - self.entry_directory = dir.map(|x| x.as_ref().to_path_buf()); - self - } } -impl Default for FileTransferParams { - fn default() -> Self { - Self::new("localhost") +// -- S3 params + +impl AwsS3Params { + /// ### new + /// + /// Instantiates a new `AwsS3Params` struct + pub fn new>(bucket: S, region: S, profile: Option) -> Self { + Self { + bucket_name: bucket.as_ref().to_string(), + region: region.as_ref().to_string(), + profile: profile.map(|x| x.as_ref().to_string()), + } } } @@ -112,26 +201,49 @@ mod test { #[test] fn test_filetransfer_params() { - let params: FileTransferParams = FileTransferParams::new("test.rebex.net") - .port(2222) - .protocol(FileTransferProtocol::Scp) - .username(Some("omar")) - .password(Some("foobar")) - .entry_directory(Some(&Path::new("/tmp"))); - assert_eq!(params.address.as_str(), "test.rebex.net"); - assert_eq!(params.port, 2222); + let params: FileTransferParams = + FileTransferParams::new(FileTransferProtocol::Scp, ProtocolParams::default()) + .entry_directory(Some(&Path::new("/tmp"))); + assert_eq!( + params.params.generic_params().unwrap().address.as_str(), + "localhost" + ); assert_eq!(params.protocol, FileTransferProtocol::Scp); - assert_eq!(params.username.as_ref().unwrap(), "omar"); - assert_eq!(params.password.as_ref().unwrap(), "foobar"); + assert_eq!( + params.entry_directory.as_deref().unwrap(), + Path::new("/tmp") + ); } #[test] - fn test_filetransfer_params_default() { - let params: FileTransferParams = FileTransferParams::default(); + fn params_default() { + let params: GenericProtocolParams = ProtocolParams::default() + .generic_params() + .unwrap() + .to_owned(); assert_eq!(params.address.as_str(), "localhost"); assert_eq!(params.port, 22); - assert_eq!(params.protocol, FileTransferProtocol::Sftp); assert!(params.username.is_none()); assert!(params.password.is_none()); } + + #[test] + fn params_aws_s3() { + let params: AwsS3Params = AwsS3Params::new("omar", "eu-west-1", Some("test")); + assert_eq!(params.bucket_name.as_str(), "omar"); + assert_eq!(params.region.as_str(), "eu-west-1"); + assert_eq!(params.profile.as_deref().unwrap(), "test"); + } + + #[test] + fn references() { + let mut params = ProtocolParams::AwsS3(AwsS3Params::new("omar", "eu-west-1", Some("test"))); + assert!(params.s3_params().is_some()); + assert!(params.generic_params().is_none()); + assert!(params.mut_generic_params().is_none()); + let mut params = ProtocolParams::default(); + assert!(params.s3_params().is_none()); + assert!(params.generic_params().is_some()); + assert!(params.mut_generic_params().is_some()); + } } diff --git a/src/filetransfer/ftp_transfer.rs b/src/filetransfer/transfer/ftp.rs similarity index 92% rename from src/filetransfer/ftp_transfer.rs rename to src/filetransfer/transfer/ftp.rs index b0f5b82..4d9ea65 100644 --- a/src/filetransfer/ftp_transfer.rs +++ b/src/filetransfer/transfer/ftp.rs @@ -1,4 +1,4 @@ -//! ## Ftp_transfer +//! ## FTP transfer //! //! `ftp_transfer` is the module which provides the implementation for the FTP/FTPS file transfer @@ -25,7 +25,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -use super::{FileTransfer, FileTransferError, FileTransferErrorType}; +use super::{FileTransfer, FileTransferError, FileTransferErrorType, ProtocolParams}; use crate::fs::{FsDirectory, FsEntry, FsFile, UnixPex}; use crate::utils::fmt::shadow_password; use crate::utils::path; @@ -178,25 +178,24 @@ impl FileTransfer for FtpFileTransfer { /// /// Connect to the remote server - fn connect( - &mut self, - address: String, - port: u16, - username: Option, - password: Option, - ) -> Result, FileTransferError> { - // Get stream - info!("Connecting to {}:{}", address, port); - let mut stream: FtpStream = match FtpStream::connect(format!("{}:{}", address, port)) { - Ok(stream) => stream, - Err(err) => { - error!("Failed to connect: {}", err); - return Err(FileTransferError::new_ex( - FileTransferErrorType::ConnectionError, - err.to_string(), - )); - } + fn connect(&mut self, params: &ProtocolParams) -> Result, FileTransferError> { + let params = match params.generic_params() { + Some(params) => params, + None => return Err(FileTransferError::new(FileTransferErrorType::BadAddress)), }; + // Get stream + info!("Connecting to {}:{}", params.address, params.port); + let mut stream: FtpStream = + match FtpStream::connect(format!("{}:{}", params.address, params.port)) { + Ok(stream) => stream, + Err(err) => { + error!("Failed to connect: {}", err); + return Err(FileTransferError::new_ex( + FileTransferErrorType::ConnectionError, + err.to_string(), + )); + } + }; // If SSL, open secure session if self.ftps { info!("Setting up TLS stream..."); @@ -214,7 +213,7 @@ impl FileTransfer for FtpFileTransfer { )); } }; - stream = match stream.into_secure(ctx, address.as_str()) { + stream = match stream.into_secure(ctx, params.address.as_str()) { Ok(s) => s, Err(err) => { error!("Failed to setup TLS stream: {}", err); @@ -226,12 +225,12 @@ impl FileTransfer for FtpFileTransfer { }; } // Login (use anonymous if credentials are unspecified) - let username: String = match username { - Some(u) => u, + let username: String = match ¶ms.username { + Some(u) => u.to_string(), None => String::from("anonymous"), }; - let password: String = match password { - Some(pwd) => pwd, + let password: String = match ¶ms.password { + Some(pwd) => pwd.to_string(), None => String::new(), }; info!( @@ -645,6 +644,7 @@ impl FileTransfer for FtpFileTransfer { mod tests { use super::*; + use crate::filetransfer::params::GenericProtocolParams; use crate::utils::file::open_file; #[cfg(feature = "with-containers")] use crate::utils::test_helpers::write_file; @@ -672,17 +672,15 @@ mod tests { // Sample file let (entry, file): (FsFile, tempfile::NamedTempFile) = create_sample_file_entry(); // Connect - #[cfg(not(feature = "github-actions"))] - let hostname: String = String::from("127.0.0.1"); - #[cfg(feature = "github-actions")] let hostname: String = String::from("127.0.0.1"); assert!(ftp - .connect( - hostname, - 10021, - Some(String::from("test")), - Some(String::from("test")), - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address(hostname) + .port(10021) + .username(Some("test")) + .password(Some("test")) + )) .is_ok()); assert_eq!(ftp.is_connected(), true); // Get pwd @@ -810,12 +808,13 @@ mod tests { let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false); // Connect assert!(ftp - .connect( - String::from("127.0.0.1"), - 10021, - Some(String::from("omar")), - Some(String::from("ommlar")), - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10021) + .username(Some("omar")) + .password(Some("ommlar")) + )) .is_err()); } @@ -824,7 +823,13 @@ mod tests { fn test_filetransfer_ftp_no_credentials() { let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false); assert!(ftp - .connect(String::from("127.0.0.1"), 10021, None, None) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10021) + .username::<&str>(None) + .password::<&str>(None) + )) .is_err()); } @@ -833,12 +838,13 @@ mod tests { let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false); // Connect assert!(ftp - .connect( - String::from("mybadserver.veryverybad.awful"), - 21, - Some(String::from("omar")), - Some(String::from("ommlar")), - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("mybad.veribad.server") + .port(21) + .username::<&str>(None) + .password::<&str>(None) + )) .is_err()); } @@ -890,12 +896,13 @@ mod tests { let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false); // Connect assert!(ftp - .connect( - String::from("test.rebex.net"), - 21, - Some(String::from("demo")), - Some(String::from("password")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("test.rebex.net") + .port(21) + .username(Some("demo")) + .password(Some("password")) + )) .is_ok()); // Pwd assert_eq!(ftp.pwd().ok().unwrap(), PathBuf::from("/")); diff --git a/src/filetransfer/transfer/mod.rs b/src/filetransfer/transfer/mod.rs new file mode 100644 index 0000000..0662bdb --- /dev/null +++ b/src/filetransfer/transfer/mod.rs @@ -0,0 +1,18 @@ +//! # transfer +//! +//! This module exposes all the file transfers supported by termscp + +// -- import +use super::{FileTransfer, FileTransferError, FileTransferErrorType, ProtocolParams}; + +// -- modules +mod ftp; +mod s3; +mod scp; +mod sftp; + +// -- export +pub use self::s3::S3FileTransfer; +pub use ftp::FtpFileTransfer; +pub use scp::ScpFileTransfer; +pub use sftp::SftpFileTransfer; diff --git a/src/filetransfer/transfer/s3/mod.rs b/src/filetransfer/transfer/s3/mod.rs new file mode 100644 index 0000000..8f167fe --- /dev/null +++ b/src/filetransfer/transfer/s3/mod.rs @@ -0,0 +1,697 @@ +//! ## S3 transfer +//! +//! S3 file transfer module + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +// -- mod +mod object; + +// Locals +use super::{FileTransfer, FileTransferError, FileTransferErrorType, ProtocolParams}; +use crate::fs::{FsDirectory, FsEntry, FsFile}; +use crate::utils::path; +use object::S3Object; + +// ext +use s3::creds::Credentials; +use s3::serde_types::Object; +use s3::{Bucket, Region}; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +/// ## S3FileTransfer +/// +/// Aws s3 file transfer +pub struct S3FileTransfer { + bucket: Option, + wrkdir: PathBuf, +} + +impl Default for S3FileTransfer { + fn default() -> Self { + Self { + bucket: None, + wrkdir: PathBuf::from("/"), + } + } +} + +impl S3FileTransfer { + /// ### list_objects + /// + /// List objects contained in `p` path + fn list_objects(&self, p: &Path, list_dir: bool) -> Result, FileTransferError> { + // Make path relative + let key: String = Self::fmt_path(p, list_dir); + debug!("Query list directory {}; key: {}", p.display(), key); + self.query_objects(key, true) + } + + /// ### stat_object + /// + /// Stat an s3 object + fn stat_object(&self, p: &Path) -> Result { + let key: String = Self::fmt_path(p, false); + debug!("Query stat object {}; key: {}", p.display(), key); + let objects = self.query_objects(key, false)?; + // Absolutize path + let absol: PathBuf = path::absolutize(Path::new("/"), p); + // Find associated object + match objects + .into_iter() + .find(|x| x.path.as_path() == absol.as_path()) + { + Some(obj) => Ok(obj), + None => Err(FileTransferError::new_ex( + FileTransferErrorType::NoSuchFileOrDirectory, + format!("{}: No such file or directory", p.display()), + )), + } + } + + /// ### query_objects + /// + /// Query objects at key + fn query_objects( + &self, + key: String, + only_direct_children: bool, + ) -> Result, FileTransferError> { + let results = self.bucket.as_ref().unwrap().list(key.clone(), None); + match results { + Ok(entries) => { + let mut objects: Vec = Vec::new(); + entries.iter().for_each(|x| { + x.contents + .iter() + .filter(|x| { + if only_direct_children { + Self::list_object_should_be_kept(x, key.as_str()) + } else { + true + } + }) + .for_each(|x| objects.push(S3Object::from(x))) + }); + debug!("Found objects: {:?}", objects); + Ok(objects) + } + Err(e) => Err(FileTransferError::new_ex( + FileTransferErrorType::DirStatFailed, + e.to_string(), + )), + } + } + + /// ### list_object_should_be_kept + /// + /// Returns whether object should be kept after list command. + /// The object won't be kept if: + /// + /// 1. is not a direct child of provided dir + fn list_object_should_be_kept(obj: &Object, dir: &str) -> bool { + Self::is_direct_child(obj.key.as_str(), dir) + } + + /// ### is_direct_child + /// + /// Checks whether Object's key is direct child of `parent` path. + fn is_direct_child(key: &str, parent: &str) -> bool { + key == format!("{}{}", parent, S3Object::object_name(key)) + || key == format!("{}{}/", parent, S3Object::object_name(key)) + } + + /// ### resolve + /// + /// Make s3 absolute path from a given path + fn resolve(&self, p: &Path) -> PathBuf { + path::diff_paths(path::absolutize(self.wrkdir.as_path(), p), &Path::new("/")) + .unwrap_or_default() + } + + /// ### fmt_fs_entry_path + /// + /// fmt path for fsentry according to format expected by s3 + fn fmt_fs_file_path(f: &FsFile) -> String { + Self::fmt_path(f.abs_path.as_path(), false) + } + + /// ### fmt_path + /// + /// fmt path for fsentry according to format expected by s3 + fn fmt_path(p: &Path, is_dir: bool) -> String { + // prevent root as slash + if p == Path::new("/") { + return "".to_string(); + } + // Remove root only if absolute + #[cfg(target_family = "unix")] + let is_absolute: bool = p.is_absolute(); + // NOTE: don't use is_absolute: on windows won't work + #[cfg(target_family = "windows")] + let is_absolute: bool = p.display().to_string().starts_with('/'); + let p: PathBuf = match is_absolute { + true => path::diff_paths(p, &Path::new("/")).unwrap_or_default(), + false => p.to_path_buf(), + }; + // NOTE: windows only: resolve paths + #[cfg(target_family = "windows")] + let p: PathBuf = PathBuf::from(path_slash::PathExt::to_slash_lossy(p.as_path()).as_str()); + // Fmt + match is_dir { + true => { + let mut p: String = p.display().to_string(); + if !p.ends_with('/') { + p.push('/'); + } + p + } + false => p.to_string_lossy().to_string(), + } + } +} + +impl FileTransfer for S3FileTransfer { + /// ### connect + /// + /// Connect to the remote server + /// Can return banner / welcome message on success + fn connect(&mut self, params: &ProtocolParams) -> Result, FileTransferError> { + // Verify parameters are S3 + let params = match params.s3_params() { + Some(params) => params, + None => return Err(FileTransferError::new(FileTransferErrorType::BadAddress)), + }; + // Load credentials + debug!("Loading credentials... (profile {:?})", params.profile); + let credentials: Credentials = + Credentials::new(None, None, None, None, params.profile.as_deref()).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::AuthenticationFailed, + format!("Could not load s3 credentials: {}", e), + ) + })?; + // Parse region + debug!("Parsing region {}", params.region); + let region: Region = Region::from_str(params.region.as_str()).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::AuthenticationFailed, + format!("Could not parse s3 region: {}", e), + ) + })?; + debug!( + "Credentials loaded! Connecting to bucket {}...", + params.bucket_name + ); + self.bucket = Some( + Bucket::new(params.bucket_name.as_str(), region, credentials).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::AuthenticationFailed, + format!("Could not connect to bucket {}: {}", params.bucket_name, e), + ) + })?, + ); + info!("Connection successfully established"); + Ok(None) + } + + /// ### disconnect + /// + /// Disconnect from the remote server + fn disconnect(&mut self) -> Result<(), FileTransferError> { + info!("Disconnecting from S3 bucket..."); + match self.bucket.take() { + Some(bucket) => { + drop(bucket); + Ok(()) + } + None => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### is_connected + /// + /// Indicates whether the client is connected to remote + fn is_connected(&self) -> bool { + self.bucket.is_some() + } + + /// ### pwd + /// + /// Print working directory + fn pwd(&mut self) -> Result { + info!("PWD"); + match self.is_connected() { + true => Ok(self.wrkdir.clone()), + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### change_dir + /// + /// Change working directory + fn change_dir(&mut self, dir: &Path) -> Result { + match &self.bucket.is_some() { + true => { + // Always allow entering root + if dir == Path::new("/") { + self.wrkdir = dir.to_path_buf(); + info!("New working directory: {}", self.wrkdir.display()); + return Ok(self.wrkdir.clone()); + } + // Check if directory exists + debug!("Entering directory {}...", dir.display()); + let dir_p: PathBuf = self.resolve(dir); + let dir_s: String = Self::fmt_path(dir_p.as_path(), true); + debug!("Searching for key {} (path: {})...", dir_s, dir_p.display()); + // Check if directory already exists + if self + .stat_object(PathBuf::from(dir_s.as_str()).as_path()) + .is_ok() + { + self.wrkdir = path::absolutize(Path::new("/"), dir_p.as_path()); + info!("New working directory: {}", self.wrkdir.display()); + Ok(self.wrkdir.clone()) + } else { + Err(FileTransferError::new( + FileTransferErrorType::NoSuchFileOrDirectory, + )) + } + } + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### copy + /// + /// Copy file to destination + fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> { + Err(FileTransferError::new( + FileTransferErrorType::UnsupportedFeature, + )) + } + + /// ### list_dir + /// + /// List directory entries + fn list_dir(&mut self, path: &Path) -> Result, FileTransferError> { + match self.is_connected() { + true => self + .list_objects(path, true) + .map(|x| x.into_iter().map(|x| x.into()).collect()), + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### mkdir + /// + /// Make directory + /// In case the directory already exists, it must return an Error of kind `FileTransferErrorType::DirectoryAlreadyExists` + fn mkdir(&mut self, dir: &Path) -> Result<(), FileTransferError> { + match &self.bucket { + Some(bucket) => { + let dir: String = Self::fmt_path(self.resolve(dir).as_path(), true); + debug!("Making directory {}...", dir); + // Check if directory already exists + if self + .stat_object(PathBuf::from(dir.as_str()).as_path()) + .is_ok() + { + error!("Directory {} already exists", dir); + return Err(FileTransferError::new( + FileTransferErrorType::DirectoryAlreadyExists, + )); + } + bucket + .put_object(dir.as_str(), &[]) + .map(|_| ()) + .map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::FileCreateDenied, + format!("Could not make directory: {}", e), + ) + }) + } + None => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### remove + /// + /// Remove a file or a directory + fn remove(&mut self, file: &FsEntry) -> Result<(), FileTransferError> { + let path = Self::fmt_path( + path::diff_paths(file.get_abs_path(), &Path::new("/")) + .unwrap_or_default() + .as_path(), + file.is_dir(), + ); + info!("Removing object {}...", path); + match &self.bucket { + Some(bucket) => bucket.delete_object(path).map(|_| ()).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::ProtocolError, + format!("Could not remove file: {}", e), + ) + }), + None => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### rename + /// + /// Rename file or a directory + fn rename(&mut self, _file: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> { + Err(FileTransferError::new( + FileTransferErrorType::UnsupportedFeature, + )) + } + + /// ### stat + /// + /// Stat file and return FsEntry + fn stat(&mut self, p: &Path) -> Result { + match self.is_connected() { + true => { + // First try as a "file" + let path: PathBuf = self.resolve(p); + if let Ok(obj) = self.stat_object(path.as_path()) { + return Ok(obj.into()); + } + // Try as a "directory" + debug!("Failed to stat object as file; trying as a directory..."); + let path: PathBuf = PathBuf::from(Self::fmt_path(path.as_path(), true)); + self.stat_object(path.as_path()).map(|x| x.into()) + } + false => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### exec + /// + /// Execute a command on remote host + fn exec(&mut self, _cmd: &str) -> Result { + Err(FileTransferError::new( + FileTransferErrorType::UnsupportedFeature, + )) + } + + /// ### send_file_wno_stream + /// + /// Send a file to remote WITHOUT using streams. + /// This method SHOULD be implemented ONLY when streams are not supported by the current file transfer. + /// The developer implementing the filetransfer user should FIRST try with `send_file` followed by `on_sent` + /// If the function returns error kind() `UnsupportedFeature`, then he should call this function. + /// By default this function uses the streams function to copy content from reader to writer + fn send_file_wno_stream( + &mut self, + _src: &FsFile, + dest: &Path, + mut reader: Box, + ) -> Result<(), FileTransferError> { + match &mut self.bucket { + Some(bucket) => { + let key = Self::fmt_path(dest, false); + info!("Query PUT for key '{}'", key); + bucket + .put_object_stream(&mut reader, key.as_str()) + .map(|_| ()) + .map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::ProtocolError, + format!("Could not put file: {}", e), + ) + }) + } + None => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } + + /// ### recv_file_wno_stream + /// + /// Receive a file from remote WITHOUT using streams. + /// This method SHOULD be implemented ONLY when streams are not supported by the current file transfer. + /// The developer implementing the filetransfer user should FIRST try with `send_file` followed by `on_sent` + /// If the function returns error kind() `UnsupportedFeature`, then he should call this function. + /// By default this function uses the streams function to copy content from reader to writer + fn recv_file_wno_stream(&mut self, src: &FsFile, dest: &Path) -> Result<(), FileTransferError> { + match &mut self.bucket { + Some(bucket) => { + let mut writer = File::create(dest).map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::FileCreateDenied, + format!("Could not open local file: {}", e), + ) + })?; + let key = Self::fmt_fs_file_path(src); + info!("Query GET for key '{}'", key); + bucket + .get_object_stream(key.as_str(), &mut writer) + .map(|_| ()) + .map_err(|e| { + FileTransferError::new_ex( + FileTransferErrorType::ProtocolError, + format!("Could not get file: {}", e), + ) + }) + } + None => Err(FileTransferError::new( + FileTransferErrorType::UninitializedSession, + )), + } + } +} + +#[cfg(test)] +mod test { + + use super::*; + #[cfg(feature = "with-s3-ci")] + use crate::filetransfer::params::AwsS3Params; + #[cfg(feature = "with-s3-ci")] + use crate::utils::random; + use crate::utils::test_helpers; + + use pretty_assertions::assert_eq; + #[cfg(feature = "with-s3-ci")] + use std::env; + #[cfg(feature = "with-s3-ci")] + use tempfile::NamedTempFile; + + #[test] + fn s3_new() { + let s3: S3FileTransfer = S3FileTransfer::default(); + assert_eq!(s3.wrkdir.as_path(), Path::new("/")); + assert!(s3.bucket.is_none()); + } + + #[test] + fn s3_is_direct_child() { + assert_eq!(S3FileTransfer::is_direct_child("pippo/", ""), true); + assert_eq!( + S3FileTransfer::is_direct_child("pippo/sottocartella/", ""), + false + ); + assert_eq!( + S3FileTransfer::is_direct_child("pippo/sottocartella/", "pippo/"), + true + ); + assert_eq!( + S3FileTransfer::is_direct_child("pippo/sottocartella/", "pippo"), // This case must be handled indeed + false + ); + assert_eq!( + S3FileTransfer::is_direct_child( + "pippo/sottocartella/readme.md", + "pippo/sottocartella/" + ), + true + ); + assert_eq!( + S3FileTransfer::is_direct_child( + "pippo/sottocartella/readme.md", + "pippo/sottocartella/" + ), + true + ); + } + + #[test] + fn s3_resolve() { + let mut s3: S3FileTransfer = S3FileTransfer::default(); + s3.wrkdir = PathBuf::from("/tmp"); + // Absolute + assert_eq!( + s3.resolve(&Path::new("/tmp/sottocartella/")).as_path(), + Path::new("tmp/sottocartella") + ); + // Relative + assert_eq!( + s3.resolve(&Path::new("subfolder/")).as_path(), + Path::new("tmp/subfolder") + ); + } + + #[test] + fn s3_fmt_fs_file_path() { + let f: FsFile = + test_helpers::make_fsentry(&Path::new("/tmp/omar.txt"), false).unwrap_file(); + assert_eq!( + S3FileTransfer::fmt_fs_file_path(&f).as_str(), + "tmp/omar.txt" + ); + } + + #[test] + fn s3_fmt_path() { + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("/tmp/omar.txt"), false).as_str(), + "tmp/omar.txt" + ); + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("omar.txt"), false).as_str(), + "omar.txt" + ); + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("/tmp/subfolder"), true).as_str(), + "tmp/subfolder/" + ); + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("tmp/subfolder"), true).as_str(), + "tmp/subfolder/" + ); + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("tmp"), true).as_str(), + "tmp/" + ); + assert_eq!( + S3FileTransfer::fmt_path(&Path::new("tmp/"), true).as_str(), + "tmp/" + ); + assert_eq!(S3FileTransfer::fmt_path(&Path::new("/"), true).as_str(), ""); + } + + // -- test transfer + #[cfg(feature = "with-s3-ci")] + #[test] + fn s3_filetransfer() { + // Gather s3 environment args + let bucket: String = env::var("AWS_S3_BUCKET").ok().unwrap(); + let region: String = env::var("AWS_S3_REGION").ok().unwrap(); + let params = get_ftparams(bucket, region); + // Get transfer + let mut s3 = S3FileTransfer::default(); + // Connect + assert!(s3.connect(¶ms).is_ok()); + // Check is connected + assert_eq!(s3.is_connected(), true); + // Pwd + assert_eq!(s3.pwd().ok().unwrap(), PathBuf::from("/")); + // Go to github-ci directory + assert!(s3.change_dir(&Path::new("/github-ci")).is_ok()); + assert_eq!(s3.pwd().ok().unwrap(), PathBuf::from("/github-ci")); + // Find + assert_eq!(s3.find("*.jpg").ok().unwrap().len(), 1); + // List directory (3 entries) + assert_eq!(s3.list_dir(&Path::new("/github-ci")).ok().unwrap().len(), 3); + // Go to playground + assert!(s3.change_dir(&Path::new("/github-ci/playground")).is_ok()); + assert_eq!( + s3.pwd().ok().unwrap(), + PathBuf::from("/github-ci/playground") + ); + // Create directory + let dir_name: String = format!("{}/", random::random_alphanumeric_with_len(8)); + let mut dir_path: PathBuf = PathBuf::from("/github-ci/playground"); + dir_path.push(dir_name.as_str()); + let dir_entry = test_helpers::make_fsentry(dir_path.as_path(), true); + assert!(s3.mkdir(dir_path.as_path()).is_ok()); + assert!(s3.change_dir(dir_path.as_path()).is_ok()); + // Copy/rename file is unsupported + assert!(s3.copy(&dir_entry, &Path::new("/copia")).is_err()); + assert!(s3.rename(&dir_entry, &Path::new("/copia")).is_err()); + // Exec is unsupported + assert!(s3.exec("omar!").is_err()); + // Stat file + let entry = s3 + .stat(&Path::new("/github-ci/avril_lavigne.jpg")) + .ok() + .unwrap() + .unwrap_file(); + assert_eq!(entry.name.as_str(), "avril_lavigne.jpg"); + assert_eq!( + entry.abs_path.as_path(), + Path::new("/github-ci/avril_lavigne.jpg") + ); + assert_eq!(entry.ftype.as_deref().unwrap(), "jpg"); + assert_eq!(entry.size, 101738); + assert_eq!(entry.user, None); + assert_eq!(entry.group, None); + assert_eq!(entry.unix_pex, None); + // Download file + let (local_file_entry, local_file): (FsFile, NamedTempFile) = + test_helpers::create_sample_file_entry(); + let remote_entry = + test_helpers::make_fsentry(&Path::new("/github-ci/avril_lavigne.jpg"), false) + .unwrap_file(); + assert!(s3 + .recv_file_wno_stream(&remote_entry, local_file.path()) + .is_ok()); + // Upload file + let mut dest_path = dir_path.clone(); + dest_path.push("aurellia_lavagna.jpg"); + let reader = Box::new(File::open(local_file.path()).ok().unwrap()); + assert!(s3 + .send_file_wno_stream(&local_file_entry, dest_path.as_path(), reader) + .is_ok()); + // Remove temp dir + assert!(s3.remove(&dir_entry).is_ok()); + // Disconnect + assert!(s3.disconnect().is_ok()); + } + + #[cfg(feature = "with-s3-ci")] + fn get_ftparams(bucket: String, region: String) -> ProtocolParams { + ProtocolParams::AwsS3(AwsS3Params::new(bucket, region, None)) + } +} diff --git a/src/filetransfer/transfer/s3/object.rs b/src/filetransfer/transfer/s3/object.rs new file mode 100644 index 0000000..245447a --- /dev/null +++ b/src/filetransfer/transfer/s3/object.rs @@ -0,0 +1,247 @@ +//! ## S3 object +//! +//! This module exposes the S3Object structure, which is an intermediate structure to work with +//! S3 objects. Easy to be converted into a FsEntry. + +/** + * MIT License + * + * termscp - Copyright (c) 2021 Christian Visintin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +use super::{FsDirectory, FsEntry, FsFile, Object}; +use crate::utils::parser::parse_datetime; +use crate::utils::path; + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// ## S3Object +/// +/// An intermediate struct to work with s3 `Object`. +/// Really easy to be converted into a `FsEntry` +#[derive(Debug)] +pub struct S3Object { + pub name: String, + pub path: PathBuf, + pub size: usize, + pub last_modified: SystemTime, + /// Whether or not represents a directory. I already know directories don't exist in s3! + pub is_dir: bool, +} + +impl From<&Object> for S3Object { + fn from(obj: &Object) -> Self { + let is_dir: bool = obj.key.ends_with('/'); + let abs_path: PathBuf = path::absolutize( + PathBuf::from("/").as_path(), + PathBuf::from(obj.key.as_str()).as_path(), + ); + let last_modified: SystemTime = + match parse_datetime(obj.last_modified.as_str(), "%Y-%m-%dT%H:%M:%S%Z") { + Ok(dt) => dt, + Err(_) => UNIX_EPOCH, + }; + Self { + name: Self::object_name(obj.key.as_str()), + path: abs_path, + size: obj.size as usize, + last_modified, + is_dir, + } + } +} + +impl From for FsEntry { + fn from(obj: S3Object) -> Self { + let abs_path: PathBuf = path::absolutize(Path::new("/"), obj.path.as_path()); + match obj.is_dir { + true => FsEntry::Directory(FsDirectory { + name: obj.name, + abs_path, + last_change_time: obj.last_modified, + last_access_time: obj.last_modified, + creation_time: obj.last_modified, + symlink: None, + user: None, + group: None, + unix_pex: None, + }), + false => FsEntry::File(FsFile { + name: obj.name, + ftype: obj + .path + .extension() + .map(|x| x.to_string_lossy().to_string()), + abs_path, + size: obj.size, + last_change_time: obj.last_modified, + last_access_time: obj.last_modified, + creation_time: obj.last_modified, + symlink: None, + user: None, + group: None, + unix_pex: None, + }), + } + } +} + +impl S3Object { + /// ### object_name + /// + /// Get object name from key + pub fn object_name(key: &str) -> String { + let mut tokens = key.split('/'); + let count = tokens.clone().count(); + let demi_last: String = match count > 1 { + true => tokens.nth(count - 2).unwrap().to_string(), + false => String::new(), + }; + if let Some(last) = tokens.last() { + // If last is not empty, return last one + if !last.is_empty() { + return last.to_string(); + } + } + // Return demi last + demi_last + } +} + +#[cfg(test)] +mod test { + + use super::*; + + use pretty_assertions::assert_eq; + use std::time::Duration; + + #[test] + fn object_to_s3object_file() { + let obj: Object = Object { + key: String::from("pippo/sottocartella/chiedo.gif"), + e_tag: String::default(), + size: 1516966, + owner: None, + storage_class: String::default(), + last_modified: String::from("2021-08-28T10:20:37.000Z"), + }; + let s3_obj: S3Object = S3Object::from(&obj); + assert_eq!(s3_obj.name.as_str(), "chiedo.gif"); + assert_eq!( + s3_obj.path.as_path(), + Path::new("/pippo/sottocartella/chiedo.gif") + ); + assert_eq!(s3_obj.size, 1516966); + assert_eq!(s3_obj.is_dir, false); + assert_eq!( + s3_obj + .last_modified + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .unwrap(), + Duration::from_secs(1630146037) + ); + } + + #[test] + fn object_to_s3object_dir() { + let obj: Object = Object { + key: String::from("temp/"), + e_tag: String::default(), + size: 0, + owner: None, + storage_class: String::default(), + last_modified: String::from("2021-08-28T10:20:37.000Z"), + }; + let s3_obj: S3Object = S3Object::from(&obj); + assert_eq!(s3_obj.name.as_str(), "temp"); + assert_eq!(s3_obj.path.as_path(), Path::new("/temp")); + assert_eq!(s3_obj.size, 0); + assert_eq!(s3_obj.is_dir, true); + assert_eq!( + s3_obj + .last_modified + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .unwrap(), + Duration::from_secs(1630146037) + ); + } + + #[test] + fn fsentry_from_s3obj_file() { + let obj: S3Object = S3Object { + name: String::from("chiedo.gif"), + path: PathBuf::from("/pippo/sottocartella/chiedo.gif"), + size: 1516966, + is_dir: false, + last_modified: UNIX_EPOCH, + }; + let entry: FsFile = FsEntry::from(obj).unwrap_file(); + assert_eq!(entry.name.as_str(), "chiedo.gif"); + assert_eq!( + entry.abs_path.as_path(), + Path::new("/pippo/sottocartella/chiedo.gif") + ); + assert_eq!(entry.creation_time, UNIX_EPOCH); + assert_eq!(entry.last_change_time, UNIX_EPOCH); + assert_eq!(entry.last_access_time, UNIX_EPOCH); + assert_eq!(entry.size, 1516966); + assert_eq!(entry.ftype.unwrap().as_str(), "gif"); + assert_eq!(entry.user, None); + assert_eq!(entry.group, None); + assert_eq!(entry.unix_pex, None); + } + + #[test] + fn fsentry_from_s3obj_directory() { + let obj: S3Object = S3Object { + name: String::from("temp"), + path: PathBuf::from("/temp"), + size: 0, + is_dir: true, + last_modified: UNIX_EPOCH, + }; + let entry: FsDirectory = FsEntry::from(obj).unwrap_dir(); + assert_eq!(entry.name.as_str(), "temp"); + assert_eq!(entry.abs_path.as_path(), Path::new("/temp")); + assert_eq!(entry.creation_time, UNIX_EPOCH); + assert_eq!(entry.last_change_time, UNIX_EPOCH); + assert_eq!(entry.last_access_time, UNIX_EPOCH); + assert_eq!(entry.user, None); + assert_eq!(entry.group, None); + assert_eq!(entry.unix_pex, None); + } + + #[test] + fn object_name() { + assert_eq!( + S3Object::object_name("pippo/sottocartella/chiedo.gif").as_str(), + "chiedo.gif" + ); + assert_eq!( + S3Object::object_name("pippo/sottocartella/").as_str(), + "sottocartella" + ); + assert_eq!(S3Object::object_name("pippo/").as_str(), "pippo"); + } +} diff --git a/src/filetransfer/scp_transfer.rs b/src/filetransfer/transfer/scp.rs similarity index 95% rename from src/filetransfer/scp_transfer.rs rename to src/filetransfer/transfer/scp.rs index 400b035..cf4e161 100644 --- a/src/filetransfer/scp_transfer.rs +++ b/src/filetransfer/transfer/scp.rs @@ -1,4 +1,4 @@ -//! ## SCP_Transfer +//! ## SCP transfer //! //! `scps_transfer` is the module which provides the implementation for the SCP file transfer @@ -26,7 +26,7 @@ * SOFTWARE. */ // Locals -use super::{FileTransfer, FileTransferError, FileTransferErrorType}; +use super::{FileTransfer, FileTransferError, FileTransferErrorType, ProtocolParams}; use crate::fs::{FsDirectory, FsEntry, FsFile, UnixPex}; use crate::system::sshkey_storage::SshKeyStorage; use crate::utils::fmt::{fmt_time, shadow_password}; @@ -333,17 +333,15 @@ impl FileTransfer for ScpFileTransfer { /// ### connect /// /// Connect to the remote server - fn connect( - &mut self, - address: String, - port: u16, - username: Option, - password: Option, - ) -> Result, FileTransferError> { + fn connect(&mut self, params: &ProtocolParams) -> Result, FileTransferError> { + let params = match params.generic_params() { + Some(params) => params, + None => return Err(FileTransferError::new(FileTransferErrorType::BadAddress)), + }; // Setup tcp stream - info!("Connecting to {}:{}", address, port); + info!("Connecting to {}:{}", params.address, params.port); let socket_addresses: Vec = - match format!("{}:{}", address, port).to_socket_addrs() { + match format!("{}:{}", params.address, params.port).to_socket_addrs() { Ok(s) => s.collect(), Err(err) => { return Err(FileTransferError::new_ex( @@ -398,14 +396,14 @@ impl FileTransfer for ScpFileTransfer { err.to_string(), )); } - let username: String = match username { - Some(u) => u, + let username: String = match ¶ms.username { + Some(u) => u.to_string(), None => String::from(""), }; // Check if it is possible to authenticate using a RSA key match self .key_storage - .resolve(address.as_str(), username.as_str()) + .resolve(params.address.as_str(), username.as_str()) { Some(rsa_key) => { debug!( @@ -418,7 +416,7 @@ impl FileTransfer for ScpFileTransfer { username.as_str(), None, rsa_key.as_path(), - password.as_deref(), + params.password.as_deref(), ) { error!("Authentication failed: {}", err); return Err(FileTransferError::new_ex( @@ -432,11 +430,16 @@ impl FileTransfer for ScpFileTransfer { debug!( "Authenticating with username {} and password {}", username, - shadow_password(password.as_deref().unwrap_or("")) + shadow_password(params.password.as_deref().unwrap_or("")) ); if let Err(err) = session.userauth_password( username.as_str(), - password.unwrap_or_else(|| String::from("")).as_str(), + params + .password + .as_ref() + .cloned() + .unwrap_or_else(|| String::from("")) + .as_str(), ) { error!("Authentication failed: {}", err); return Err(FileTransferError::new_ex( @@ -942,36 +945,13 @@ impl FileTransfer for ScpFileTransfer { )), } } - - /// ### on_sent - /// - /// Finalize send method. - /// This method must be implemented only if necessary; in case you don't need it, just return `Ok(())` - /// The purpose of this method is to finalize the connection with the peer when writing data. - /// This is necessary for some protocols such as FTP. - /// You must call this method each time you want to finalize the write of the remote file. - fn on_sent(&mut self, _writable: Box) -> Result<(), FileTransferError> { - // Nothing to do - Ok(()) - } - - /// ### on_recv - /// - /// Finalize recv method. - /// This method must be implemented only if necessary; in case you don't need it, just return `Ok(())` - /// The purpose of this method is to finalize the connection with the peer when reading data. - /// This mighe be necessary for some protocols. - /// You must call this method each time you want to finalize the read of the remote file. - fn on_recv(&mut self, _readable: Box) -> Result<(), FileTransferError> { - // Nothing to do - Ok(()) - } } #[cfg(test)] mod tests { use super::*; + use crate::filetransfer::params::GenericProtocolParams; use crate::utils::test_helpers::make_fsentry; use pretty_assertions::assert_eq; @@ -993,12 +973,13 @@ mod tests { let (entry, file): (FsFile, tempfile::NamedTempFile) = create_sample_file_entry(); // Connect assert!(client - .connect( - String::from("127.0.0.1"), - 10222, - Some(String::from("sftp")), - Some(String::from("password")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10222) + .username(Some("sftp")) + .password(Some("password")) + )) .is_ok()); // Check session and sftp assert!(client.session.is_some()); @@ -1180,12 +1161,13 @@ mod tests { let mut client: ScpFileTransfer = ScpFileTransfer::new(storage); // Connect assert!(client - .connect( - String::from("127.0.0.1"), - 10222, - Some(String::from("sftp")), - None, - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10222) + .username(Some("sftp")) + .password::<&str>(None) + )) .is_ok()); assert_eq!(client.is_connected(), true); assert!(client.disconnect().is_ok()); @@ -1195,12 +1177,13 @@ mod tests { fn test_filetransfer_scp_bad_auth() { let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect( - String::from("127.0.0.1"), - 10222, - Some(String::from("demo")), - Some(String::from("badpassword")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10222) + .username(Some("sftp")) + .password(Some("badpassword")) + )) .is_err()); } @@ -1209,7 +1192,13 @@ mod tests { fn test_filetransfer_scp_no_credentials() { let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect(String::from("127.0.0.1"), 10222, None, None) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10222) + .username::<&str>(None) + .password::<&str>(None) + )) .is_err()); } @@ -1217,12 +1206,13 @@ mod tests { fn test_filetransfer_scp_bad_server() { let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect( - String::from("mybadserver.veryverybad.awful"), - 22, - None, - None - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("mybad.verybad.server") + .port(10222) + .username(Some("sftp")) + .password(Some("password")) + )) .is_err()); } diff --git a/src/filetransfer/sftp_transfer.rs b/src/filetransfer/transfer/sftp.rs similarity index 91% rename from src/filetransfer/sftp_transfer.rs rename to src/filetransfer/transfer/sftp.rs index ca4ea96..7e86455 100644 --- a/src/filetransfer/sftp_transfer.rs +++ b/src/filetransfer/transfer/sftp.rs @@ -1,4 +1,4 @@ -//! ## SFTP_Transfer +//! ## SFTP transfer //! //! `sftp_transfer` is the module which provides the implementation for the SFTP file transfer @@ -26,7 +26,7 @@ * SOFTWARE. */ // Locals -use super::{FileTransfer, FileTransferError, FileTransferErrorType}; +use super::{FileTransfer, FileTransferError, FileTransferErrorType, ProtocolParams}; use crate::fs::{FsDirectory, FsEntry, FsFile, UnixPex}; use crate::system::sshkey_storage::SshKeyStorage; use crate::utils::fmt::{fmt_time, shadow_password}; @@ -257,17 +257,15 @@ impl FileTransfer for SftpFileTransfer { /// ### connect /// /// Connect to the remote server - fn connect( - &mut self, - address: String, - port: u16, - username: Option, - password: Option, - ) -> Result, FileTransferError> { + fn connect(&mut self, params: &ProtocolParams) -> Result, FileTransferError> { + let params = match params.generic_params() { + Some(params) => params, + None => return Err(FileTransferError::new(FileTransferErrorType::BadAddress)), + }; // Setup tcp stream - info!("Connecting to {}:{}", address, port); + info!("Connecting to {}:{}", params.address, params.port); let socket_addresses: Vec = - match format!("{}:{}", address, port).to_socket_addrs() { + match format!("{}:{}", params.address, params.port).to_socket_addrs() { Ok(s) => s.collect(), Err(err) => { return Err(FileTransferError::new_ex( @@ -321,14 +319,14 @@ impl FileTransfer for SftpFileTransfer { err.to_string(), )); } - let username: String = match username { - Some(u) => u, + let username: String = match ¶ms.username { + Some(u) => u.to_string(), None => String::from(""), }; // Check if it is possible to authenticate using a RSA key match self .key_storage - .resolve(address.as_str(), username.as_str()) + .resolve(params.address.as_str(), username.as_str()) { Some(rsa_key) => { debug!( @@ -341,7 +339,7 @@ impl FileTransfer for SftpFileTransfer { username.as_str(), None, rsa_key.as_path(), - password.as_deref(), + params.password.as_deref(), ) { error!("Authentication failed: {}", err); return Err(FileTransferError::new_ex( @@ -355,11 +353,16 @@ impl FileTransfer for SftpFileTransfer { debug!( "Authenticating with username {} and password {}", username, - shadow_password(password.as_deref().unwrap_or("")) + shadow_password(params.password.as_deref().unwrap_or("")) ); if let Err(err) = session.userauth_password( username.as_str(), - password.unwrap_or_else(|| String::from("")).as_str(), + params + .password + .as_ref() + .cloned() + .unwrap_or_else(|| String::from("")) + .as_str(), ) { error!("Authentication failed: {}", err); return Err(FileTransferError::new_ex( @@ -766,36 +769,21 @@ impl FileTransfer for SftpFileTransfer { } } } - - /// ### on_sent - /// - /// Finalize send method. This method must be implemented only if necessary. - /// The purpose of this method is to finalize the connection with the peer when writing data. - /// This is necessary for some protocols such as FTP. - /// You must call this method each time you want to finalize the write of the remote file. - fn on_sent(&mut self, _writable: Box) -> Result<(), FileTransferError> { - Ok(()) - } - - /// ### on_recv - /// - /// Finalize recv method. This method must be implemented only if necessary. - /// The purpose of this method is to finalize the connection with the peer when reading data. - /// This mighe be necessary for some protocols. - /// You must call this method each time you want to finalize the read of the remote file. - fn on_recv(&mut self, _readable: Box) -> Result<(), FileTransferError> { - Ok(()) - } } #[cfg(test)] mod tests { use super::*; + use crate::filetransfer::params::GenericProtocolParams; use crate::utils::test_helpers::make_fsentry; #[cfg(feature = "with-containers")] - use crate::utils::test_helpers::{create_sample_file_entry, write_file, write_ssh_key}; + use crate::utils::test_helpers::{ + create_sample_file, create_sample_file_entry, write_file, write_ssh_key, + }; use pretty_assertions::assert_eq; + #[cfg(feature = "with-containers")] + use std::fs::File; #[test] fn test_filetransfer_sftp_new() { @@ -814,12 +802,13 @@ mod tests { let (entry, file): (FsFile, tempfile::NamedTempFile) = create_sample_file_entry(); // Connect assert!(client - .connect( - String::from("127.0.0.1"), - 10022, - Some(String::from("sftp")), - Some(String::from("password")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10022) + .username(Some("sftp")) + .password(Some("password")) + )) .is_ok()); // Check session and sftp assert!(client.session.is_some()); @@ -889,6 +878,11 @@ mod tests { .unwrap(); write_file(&file, &mut writable); assert!(client.on_sent(writable).is_ok()); + // Upload file without stream + let reader = Box::new(File::open(entry.abs_path.as_path()).ok().unwrap()); + assert!(client + .send_file_wno_stream(&entry, PathBuf::from("README2.md").as_path(), reader) + .is_ok()); // Upload file (err) assert!(client .send_file(&entry, PathBuf::from("/ommlar/omarone").as_path()) @@ -898,10 +892,10 @@ mod tests { .list_dir(PathBuf::from("/tmp/omar").as_path()) .ok() .unwrap(); - assert_eq!(list.len(), 2); + assert_eq!(list.len(), 3); // Find assert_eq!(client.find("*.txt").ok().unwrap().len(), 1); - assert_eq!(client.find("*.md").ok().unwrap().len(), 1); + assert_eq!(client.find("*.md").ok().unwrap().len(), 2); assert_eq!(client.find("*.jpeg").ok().unwrap().len(), 0); // Rename assert!(client @@ -955,6 +949,9 @@ mod tests { let mut data: Vec = vec![0; 1024]; assert!(readable.read(&mut data).is_ok()); assert!(client.on_recv(readable).is_ok()); + let dest_file = create_sample_file(); + // Receive file wno stream + assert!(client.recv_file_wno_stream(&file, dest_file.path()).is_ok()); // Receive file (err) assert!(client.recv_file(&entry).is_err()); // Cleanup @@ -979,12 +976,13 @@ mod tests { let mut client: SftpFileTransfer = SftpFileTransfer::new(storage); // Connect assert!(client - .connect( - String::from("127.0.0.1"), - 10022, - Some(String::from("sftp")), - None, - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10022) + .username(Some("sftp")) + .password::<&str>(None) + )) .is_ok()); assert_eq!(client.is_connected(), true); assert!(client.disconnect().is_ok()); @@ -994,12 +992,13 @@ mod tests { fn test_filetransfer_sftp_bad_auth() { let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect( - String::from("127.0.0.1"), - 10022, - Some(String::from("demo")), - Some(String::from("badpassword")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10022) + .username(Some("sftp")) + .password(Some("badpassword")) + )) .is_err()); } @@ -1008,7 +1007,13 @@ mod tests { fn test_filetransfer_sftp_no_credentials() { let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect(String::from("127.0.0.1"), 10022, None, None) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10022) + .username::<&str>(None) + .password::<&str>(None) + )) .is_err()); } @@ -1018,12 +1023,13 @@ mod tests { let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty()); // Connect assert!(client - .connect( - String::from("127.0.0.1"), - 10022, - Some(String::from("sftp")), - Some(String::from("password")) - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("127.0.0.1") + .port(10022) + .username(Some("sftp")) + .password(Some("password")) + )) .is_ok()); // get realpath assert!(client @@ -1054,12 +1060,13 @@ mod tests { fn test_filetransfer_sftp_bad_server() { let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty()); assert!(client - .connect( - String::from("mybadserver.veryverybad.awful"), - 22, - None, - None - ) + .connect(&ProtocolParams::Generic( + GenericProtocolParams::default() + .address("myverybad.verybad.server") + .port(10022) + .username(Some("sftp")) + .password(Some("password")) + )) .is_err()); } diff --git a/src/host/mod.rs b/src/host/mod.rs index 4440840..1061079 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -38,7 +38,9 @@ use std::fs::set_permissions; use std::os::unix::fs::{MetadataExt, PermissionsExt}; // Locals -use crate::fs::{FsDirectory, FsEntry, FsFile, UnixPex}; +#[cfg(target_family = "unix")] +use crate::fs::UnixPex; +use crate::fs::{FsDirectory, FsEntry, FsFile}; use crate::utils::path; /// ## HostErrorType diff --git a/src/lib.rs b/src/lib.rs index b1f2840..40475e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,7 @@ extern crate open; extern crate path_slash; extern crate rand; extern crate regex; +extern crate s3; extern crate ssh2; extern crate suppaftp; extern crate tempfile; diff --git a/src/main.rs b/src/main.rs index 2797cbd..126bbee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,7 +66,12 @@ enum Task { #[derive(FromArgs)] #[argh(description = " -where positional can be: [protocol://user@address:port:wrkdir] [local-wrkdir] +where positional can be: [address] [local-wrkdir] + +Address syntax can be: + + - `protocol://user@address:port:wrkdir` for protocols such as Sftp, Scp, Ftp + - `s3://bucket-name@region:profile:/wrkdir` for Aws S3 protocol Please, report issues to Please, consider supporting the author ")] @@ -180,7 +185,9 @@ fn parse_args(args: Args) -> Result { Ok(mut remote) => { // If password is provided, set password if let Some(passwd) = args.password { - remote = remote.password(Some(passwd)); + if let Some(mut params) = remote.params.mut_generic_params() { + params.password = Some(passwd); + } } // Set params run_opts.remote = Some(remote); @@ -209,25 +216,26 @@ fn parse_args(args: Args) -> Result { fn read_password(run_opts: &mut RunOpts) -> Result<(), String> { // Initialize client if necessary if let Some(remote) = run_opts.remote.as_mut() { - debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", remote.address, remote.port, remote.protocol, remote.username, utils::fmt::shadow_password(remote.password.as_deref().unwrap_or(""))); - if remote.password.is_none() { - // Ask password if unspecified - remote.password = match rpassword::read_password_from_tty(Some("Password: ")) { - Ok(p) => { - if p.is_empty() { - None - } else { - debug!( - "Read password from tty: {}", - utils::fmt::shadow_password(p.as_str()) - ); - Some(p) + if let Some(mut params) = remote.params.mut_generic_params() { + if params.password.is_none() { + // Ask password if unspecified + params.password = match rpassword::read_password_from_tty(Some("Password: ")) { + Ok(p) => { + if p.is_empty() { + None + } else { + debug!( + "Read password from tty: {}", + utils::fmt::shadow_password(p.as_str()) + ); + Some(p) + } } - } - Err(_) => { - return Err("Could not read password from prompt".to_string()); - } - }; + Err(_) => { + return Err("Could not read password from prompt".to_string()); + } + }; + } } } Ok(()) diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index 4fc4805..bac7ee1 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -34,14 +34,13 @@ use crate::config::{ bookmarks::{Bookmark, UserHosts}, serialization::{deserialize, serialize, SerializerError, SerializerErrorKind}, }; -use crate::filetransfer::FileTransferProtocol; +use crate::filetransfer::FileTransferParams; use crate::utils::crypto; use crate::utils::fmt::fmt_time; use crate::utils::random::random_alphanumeric_with_len; // Ext use std::fs::OpenOptions; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::string::ToString; use std::time::SystemTime; @@ -166,59 +165,45 @@ impl BookmarksClient { /// ### get_bookmark /// /// Get bookmark associated to key - pub fn get_bookmark( - &self, - key: &str, - ) -> Option<(String, u16, FileTransferProtocol, String, Option)> { - let entry: &Bookmark = self.hosts.bookmarks.get(key)?; + pub fn get_bookmark(&self, key: &str) -> Option { debug!("Getting bookmark {}", key); - Some(( - entry.address.clone(), - entry.port, - match FileTransferProtocol::from_str(entry.protocol.as_str()) { - Ok(proto) => proto, - Err(err) => { - error!( - "Found invalid protocol in bookmarks: {}; defaulting to SFTP", - err - ); - FileTransferProtocol::Sftp // Default + let mut entry: Bookmark = self.hosts.bookmarks.get(key).cloned()?; + // Decrypt password first + if let Some(pwd) = entry.password.as_mut() { + match self.decrypt_str(pwd.as_str()) { + Ok(decrypted_pwd) => { + *pwd = decrypted_pwd; } - }, - entry.username.clone(), - match &entry.password { - // Decrypted password if Some; if decryption fails return None - Some(pwd) => match self.decrypt_str(pwd.as_str()) { - Ok(decrypted_pwd) => Some(decrypted_pwd), - Err(err) => { - error!("Failed to decrypt password for bookmark: {}", err); - None - } - }, - None => None, - }, - )) + Err(err) => { + error!("Failed to decrypt password for bookmark: {}", err); + } + } + } + // Then convert into + Some(FileTransferParams::from(entry)) } /// ### add_recent /// /// Add a new recent to bookmarks - pub fn add_bookmark( + pub fn add_bookmark>( &mut self, - name: String, - addr: String, - port: u16, - protocol: FileTransferProtocol, - username: String, - password: Option, + name: S, + params: FileTransferParams, + save_password: bool, ) { + let name: String = name.as_ref().to_string(); if name.is_empty() { error!("Fatal error; bookmark name is empty"); panic!("Bookmark name can't be empty"); } // Make bookmark - info!("Added bookmark {} with address {}", name, addr); - let host: Bookmark = self.make_bookmark(addr, port, protocol, username, password); + info!("Added bookmark {}", name); + let mut host: Bookmark = self.make_bookmark(params); + // If not save_password, set password to `None` + if !save_password { + host.password = None; + } self.hosts.bookmarks.insert(name, host); } @@ -239,43 +224,25 @@ impl BookmarksClient { /// ### get_recent /// /// Get recent associated to key - pub fn get_recent(&self, key: &str) -> Option<(String, u16, FileTransferProtocol, String)> { + pub fn get_recent(&self, key: &str) -> Option { // NOTE: password is not decrypted; recents will never have password info!("Getting bookmark {}", key); - let entry: &Bookmark = self.hosts.recents.get(key)?; - Some(( - entry.address.clone(), - entry.port, - match FileTransferProtocol::from_str(entry.protocol.as_str()) { - Ok(proto) => proto, - Err(err) => { - error!( - "Found invalid protocol in bookmarks: {}; defaulting to SFTP", - err - ); - FileTransferProtocol::Sftp // Default - } - }, - entry.username.clone(), - )) + let entry: Bookmark = self.hosts.recents.get(key).cloned()?; + Some(FileTransferParams::from(entry)) } /// ### add_recent /// /// Add a new recent to bookmarks - pub fn add_recent( - &mut self, - addr: String, - port: u16, - protocol: FileTransferProtocol, - username: String, - ) { + pub fn add_recent(&mut self, params: FileTransferParams) { // Make bookmark - let host: Bookmark = self.make_bookmark(addr, port, protocol, username, None); + let mut host: Bookmark = self.make_bookmark(params); + // Null password for recents + host.password = None; // Check if duplicated - for recent_host in self.hosts.recents.values() { - if *recent_host == host { - debug!("Discarding recent since duplicated ({})", host.address); + for (key, value) in &self.hosts.recents { + if *value == host { + debug!("Discarding recent since duplicated ({})", key); // Don't save duplicates return; } @@ -300,7 +267,7 @@ impl BookmarksClient { } } let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S"); - info!("Saved recent host {} ({})", name, host.address); + info!("Saved recent host {}", name); self.hosts.recents.insert(name, host); } @@ -376,21 +343,13 @@ impl BookmarksClient { /// ### make_bookmark /// /// Make bookmark from credentials - fn make_bookmark( - &self, - addr: String, - port: u16, - protocol: FileTransferProtocol, - username: String, - password: Option, - ) -> Bookmark { - Bookmark { - address: addr, - port, - username, - protocol: protocol.to_string(), - password: password.map(|p| self.encrypt_str(p.as_str())), + fn make_bookmark(&self, params: FileTransferParams) -> Bookmark { + let mut bookmark: Bookmark = Bookmark::from(params); + // Encrypt password + if let Some(pwd) = bookmark.password { + bookmark.password = Some(self.encrypt_str(pwd.as_str())); } + bookmark } /// ### encrypt_str @@ -419,6 +378,8 @@ impl BookmarksClient { mod tests { use super::*; + use crate::filetransfer::params::GenericProtocolParams; + use crate::filetransfer::{FileTransferProtocol, ProtocolParams}; use pretty_assertions::assert_eq; use std::thread::sleep; @@ -473,19 +434,23 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add some bookmarks client.add_bookmark( - String::from("raspberry"), - String::from("192.168.1.31"), - 22, - FileTransferProtocol::Sftp, - String::from("pi"), - Some(String::from("mypassword")), + "raspberry", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + ), + true, ); - client.add_recent( - String::from("192.168.1.31"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + )); let recent_key: String = String::from(client.iter_recents().next().unwrap()); assert!(client.write_bookmarks().is_ok()); let key: String = client.key.clone(); @@ -494,19 +459,18 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Verify it loaded parameters correctly assert_eq!(client.key, key); - let bookmark: (String, u16, FileTransferProtocol, String, Option) = - client.get_bookmark(&String::from("raspberry")).unwrap(); + let bookmark = ftparams_to_tup(client.get_bookmark("raspberry").unwrap()); assert_eq!(bookmark.0, String::from("192.168.1.31")); assert_eq!(bookmark.1, 22); assert_eq!(bookmark.2, FileTransferProtocol::Sftp); assert_eq!(bookmark.3, String::from("pi")); assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword")); - let bookmark: (String, u16, FileTransferProtocol, String) = - client.get_recent(&recent_key).unwrap(); + let bookmark = ftparams_to_tup(client.get_recent(&recent_key).unwrap()); assert_eq!(bookmark.0, String::from("192.168.1.31")); assert_eq!(bookmark.1, 22); assert_eq!(bookmark.2, FileTransferProtocol::Sftp); assert_eq!(bookmark.3, String::from("pi")); + assert_eq!(bookmark.4, None); } #[test] @@ -519,26 +483,31 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add bookmark client.add_bookmark( - String::from("raspberry"), - String::from("192.168.1.31"), - 22, - FileTransferProtocol::Sftp, - String::from("pi"), - Some(String::from("mypassword")), + "raspberry", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + ), + true, ); client.add_bookmark( - String::from("raspberry2"), - String::from("192.168.1.32"), - 22, - FileTransferProtocol::Sftp, - String::from("pi"), - Some(String::from("mypassword2")), + "raspberry2", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword2"), + ), + true, ); // Iter assert_eq!(client.iter_bookmarks().count(), 2); // Get bookmark - let bookmark: (String, u16, FileTransferProtocol, String, Option) = - client.get_bookmark(&String::from("raspberry")).unwrap(); + let bookmark = ftparams_to_tup(client.get_bookmark(&String::from("raspberry")).unwrap()); assert_eq!(bookmark.0, String::from("192.168.1.31")); assert_eq!(bookmark.1, 22); assert_eq!(bookmark.2, FileTransferProtocol::Sftp); @@ -565,15 +534,45 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add bookmark client.add_bookmark( - String::from(""), - String::from("192.168.1.31"), - 22, - FileTransferProtocol::Sftp, - String::from("pi"), - Some(String::from("mypassword")), + "", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + ), + true, ); } + #[test] + fn save_bookmark_wno_password() { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + // Initialize a new bookmarks client + let mut client: BookmarksClient = + BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); + // Add bookmark + client.add_bookmark( + "raspberry", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + ), + false, + ); + let bookmark = ftparams_to_tup(client.get_bookmark(&String::from("raspberry")).unwrap()); + assert_eq!(bookmark.0, String::from("192.168.1.31")); + assert_eq!(bookmark.1, 22); + assert_eq!(bookmark.2, FileTransferProtocol::Sftp); + assert_eq!(bookmark.3, String::from("pi")); + assert_eq!(bookmark.4, None); + } + #[test] fn test_system_bookmarks_manipulate_recents() { @@ -583,22 +582,23 @@ mod tests { let mut client: BookmarksClient = BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add bookmark - client.add_recent( - String::from("192.168.1.31"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + )); // Iter assert_eq!(client.iter_recents().count(), 1); let key: String = String::from(client.iter_recents().next().unwrap()); // Get bookmark - let bookmark: (String, u16, FileTransferProtocol, String) = - client.get_recent(&key).unwrap(); + let bookmark = ftparams_to_tup(client.get_recent(&key).unwrap()); assert_eq!(bookmark.0, String::from("192.168.1.31")); assert_eq!(bookmark.1, 22); assert_eq!(bookmark.2, FileTransferProtocol::Sftp); assert_eq!(bookmark.3, String::from("pi")); + assert_eq!(bookmark.4, None); // Write bookmarks assert!(client.write_bookmarks().is_ok()); // Delete bookmark @@ -618,18 +618,20 @@ mod tests { let mut client: BookmarksClient = BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add bookmark - client.add_recent( - String::from("192.168.1.31"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); - client.add_recent( - String::from("192.168.1.31"), + "192.168.1.31", 22, + "pi", + Some("mypassword"), + )); + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + )); // There should be only one recent assert_eq!(client.iter_recents().count(), 1); } @@ -644,39 +646,60 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 2).unwrap(); // Add recent, wait 1 second for each one (cause the name depends on time) // 1 - client.add_recent( - String::from("192.168.1.1"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.1", + 22, + "pi", + Some("mypassword"), + )); sleep(Duration::from_secs(1)); // 2 - client.add_recent( - String::from("192.168.1.2"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.2", + 22, + "pi", + Some("mypassword"), + )); sleep(Duration::from_secs(1)); // 3 - client.add_recent( - String::from("192.168.1.3"), - 22, + client.add_recent(make_generic_ftparams( FileTransferProtocol::Sftp, - String::from("pi"), - ); + "192.168.1.3", + 22, + "pi", + Some("mypassword"), + )); // Limit is 2 assert_eq!(client.iter_recents().count(), 2); // Check that 192.168.1.1 has been removed let key: String = client.iter_recents().nth(0).unwrap().to_string(); assert!(matches!( - client.hosts.recents.get(&key).unwrap().address.as_str(), + client + .hosts + .recents + .get(&key) + .unwrap() + .address + .as_ref() + .cloned() + .unwrap_or_default() + .as_str(), "192.168.1.2" | "192.168.1.3" )); let key: String = client.iter_recents().nth(1).unwrap().to_string(); assert!(matches!( - client.hosts.recents.get(&key).unwrap().address.as_str(), + client + .hosts + .recents + .get(&key) + .unwrap() + .address + .as_ref() + .cloned() + .unwrap_or_default() + .as_str(), "192.168.1.2" | "192.168.1.3" )); } @@ -691,12 +714,15 @@ mod tests { BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap(); // Add bookmark client.add_bookmark( - String::from(""), - String::from("192.168.1.31"), - 22, - FileTransferProtocol::Sftp, - String::from("pi"), - Some(String::from("mypassword")), + "", + make_generic_ftparams( + FileTransferProtocol::Sftp, + "192.168.1.31", + 22, + "pi", + Some("mypassword"), + ), + true, ); } @@ -724,4 +750,35 @@ mod tests { c.push("bookmarks.toml"); (c, k) } + + fn make_generic_ftparams( + protocol: FileTransferProtocol, + address: &str, + port: u16, + username: &str, + password: Option<&str>, + ) -> FileTransferParams { + let params = ProtocolParams::Generic( + GenericProtocolParams::default() + .address(address) + .port(port) + .username(Some(username)) + .password(password), + ); + FileTransferParams::new(protocol, params) + } + + fn ftparams_to_tup( + params: FileTransferParams, + ) -> (String, u16, FileTransferProtocol, String, Option) { + let protocol = params.protocol; + let p = params.params.generic_params().unwrap(); + ( + p.address.to_string(), + p.port, + protocol, + p.username.as_ref().cloned().unwrap_or_default(), + p.password.as_ref().cloned(), + ) + } } diff --git a/src/ui/activities/auth/bookmarks.rs b/src/ui/activities/auth/bookmarks.rs index dcd9946..11b0a34 100644 --- a/src/ui/activities/auth/bookmarks.rs +++ b/src/ui/activities/auth/bookmarks.rs @@ -26,14 +26,15 @@ * SOFTWARE. */ // Locals -use super::{AuthActivity, FileTransferProtocol}; +use super::{AuthActivity, FileTransferParams}; +use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams, ProtocolParams}; use crate::system::bookmarks_client::BookmarksClient; use crate::system::environment; // Ext use std::path::PathBuf; use tui_realm_stdlib::{input::InputPropsBuilder, radio::RadioPropsBuilder}; -use tuirealm::{Payload, PropsBuilder, Value}; +use tuirealm::PropsBuilder; impl AuthActivity { /// ### del_bookmark @@ -62,9 +63,7 @@ impl AuthActivity { if let Some(key) = self.bookmarks_list.get(idx) { if let Some(bookmark) = bookmarks_cli.get_bookmark(key) { // Load parameters into components - self.load_bookmark_into_gui( - bookmark.0, bookmark.1, bookmark.2, bookmark.3, bookmark.4, - ); + self.load_bookmark_into_gui(bookmark); } } } @@ -74,20 +73,15 @@ impl AuthActivity { /// /// Save current input fields as a bookmark pub(super) fn save_bookmark(&mut self, name: String, save_password: bool) { - let (address, port, protocol, username, password) = self.get_input(); + let params = match self.collect_host_params() { + Ok(p) => p, + Err(e) => { + self.mount_error(e); + return; + } + }; if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() { - // Check if password must be saved - let password: Option = match save_password { - true => match self - .view - .get_state(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD) - { - Some(Payload::One(Value::Usize(0))) => Some(password), // Yes - _ => None, // No such component / No - }, - false => None, - }; - bookmarks_cli.add_bookmark(name.clone(), address, port, protocol, username, password); + bookmarks_cli.add_bookmark(name.clone(), params, save_password); // Save bookmarks self.write_bookmarks(); // Remove `name` from bookmarks if exists @@ -122,9 +116,7 @@ impl AuthActivity { if let Some(key) = self.recents_list.get(idx) { if let Some(bookmark) = client.get_recent(key) { // Load parameters - self.load_bookmark_into_gui( - bookmark.0, bookmark.1, bookmark.2, bookmark.3, None, - ); + self.load_bookmark_into_gui(bookmark); } } } @@ -134,9 +126,15 @@ impl AuthActivity { /// /// Save current input fields as a "recent" pub(super) fn save_recent(&mut self) { - let (address, port, protocol, username, _password) = self.get_input(); + let params = match self.collect_host_params() { + Ok(p) => p, + Err(e) => { + self.mount_error(e); + return; + } + }; if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() { - bookmarks_cli.add_recent(address, port, protocol, username); + bookmarks_cli.add_recent(params); // Save bookmarks self.write_bookmarks(); } @@ -234,40 +232,66 @@ impl AuthActivity { /// ### load_bookmark_into_gui /// /// Load bookmark data into the gui components - fn load_bookmark_into_gui( - &mut self, - addr: String, - port: u16, - protocol: FileTransferProtocol, - username: String, - password: Option, - ) { + fn load_bookmark_into_gui(&mut self, bookmark: FileTransferParams) { // Load parameters into components + if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) { + let props = RadioPropsBuilder::from(props) + .with_value(Self::protocol_enum_to_opt(bookmark.protocol)) + .build(); + self.view.update(super::COMPONENT_RADIO_PROTOCOL, props); + } + match bookmark.params { + ProtocolParams::AwsS3(params) => self.load_bookmark_s3_into_gui(params), + ProtocolParams::Generic(params) => self.load_bookmark_generic_into_gui(params), + } + } + + fn load_bookmark_generic_into_gui(&mut self, params: GenericProtocolParams) { if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_ADDR) { - let props = InputPropsBuilder::from(props).with_value(addr).build(); + let props = InputPropsBuilder::from(props) + .with_value(params.address.clone()) + .build(); self.view.update(super::COMPONENT_INPUT_ADDR, props); } if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PORT) { let props = InputPropsBuilder::from(props) - .with_value(port.to_string()) + .with_value(params.port.to_string()) .build(); self.view.update(super::COMPONENT_INPUT_PORT, props); } - if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) { - let props = RadioPropsBuilder::from(props) - .with_value(Self::protocol_enum_to_opt(protocol)) - .build(); - self.view.update(super::COMPONENT_RADIO_PROTOCOL, props); - } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_USERNAME) { - let props = InputPropsBuilder::from(props).with_value(username).build(); + let props = InputPropsBuilder::from(props) + .with_value(params.username.as_deref().unwrap_or_default().to_string()) + .build(); self.view.update(super::COMPONENT_INPUT_USERNAME, props); } - if let Some(password) = password { - if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) { - let props = InputPropsBuilder::from(props).with_value(password).build(); - self.view.update(super::COMPONENT_INPUT_PASSWORD, props); - } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) { + let props = InputPropsBuilder::from(props) + .with_value(params.password.as_deref().unwrap_or_default().to_string()) + .build(); + self.view.update(super::COMPONENT_INPUT_PASSWORD, props); + } + } + + fn load_bookmark_s3_into_gui(&mut self, params: AwsS3Params) { + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_S3_BUCKET) { + let props = InputPropsBuilder::from(props) + .with_value(params.bucket_name.clone()) + .build(); + self.view.update(super::COMPONENT_INPUT_S3_BUCKET, props); + } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_S3_REGION) { + let props = InputPropsBuilder::from(props) + .with_value(params.region.clone()) + .build(); + self.view.update(super::COMPONENT_INPUT_S3_REGION, props); + } + if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_S3_PROFILE) { + let props = InputPropsBuilder::from(props) + .with_value(params.profile.as_deref().unwrap_or_default().to_string()) + .build(); + self.view.update(super::COMPONENT_INPUT_S3_PROFILE, props); } } } diff --git a/src/ui/activities/auth/misc.rs b/src/ui/activities/auth/misc.rs index 85e9545..ecdf57e 100644 --- a/src/ui/activities/auth/misc.rs +++ b/src/ui/activities/auth/misc.rs @@ -26,6 +26,7 @@ * SOFTWARE. */ use super::{AuthActivity, FileTransferParams, FileTransferProtocol}; +use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams, ProtocolParams}; impl AuthActivity { /// ### protocol_opt_to_enum @@ -36,6 +37,7 @@ impl AuthActivity { 1 => FileTransferProtocol::Scp, 2 => FileTransferProtocol::Ftp(false), 3 => FileTransferProtocol::Ftp(true), + 4 => FileTransferProtocol::AwsS3, _ => FileTransferProtocol::Sftp, } } @@ -49,6 +51,7 @@ impl AuthActivity { FileTransferProtocol::Scp => 1, FileTransferProtocol::Ftp(false) => 2, FileTransferProtocol::Ftp(true) => 3, + FileTransferProtocol::AwsS3 => 4, } } @@ -59,6 +62,7 @@ impl AuthActivity { match protocol { FileTransferProtocol::Sftp | FileTransferProtocol::Scp => 22, FileTransferProtocol::Ftp(_) => 21, + FileTransferProtocol::AwsS3 => 22, // Doesn't matter, since not used } } @@ -83,15 +87,24 @@ impl AuthActivity { /// ### collect_host_params /// - /// Get input values from fields or return an error if fields are invalid + /// Collect host params as `FileTransferParams` pub(super) fn collect_host_params(&self) -> Result { - let (address, port, protocol, username, password): ( - String, - u16, - FileTransferProtocol, - String, - String, - ) = self.get_input(); + let protocol: FileTransferProtocol = self.get_protocol(); + match protocol { + FileTransferProtocol::AwsS3 => self.collect_s3_host_params(protocol), + protocol => self.collect_generic_host_params(protocol), + } + } + + /// ### collect_generic_host_params + /// + /// Get input values from fields or return an error if fields are invalid to work as generic + pub(super) fn collect_generic_host_params( + &self, + protocol: FileTransferProtocol, + ) -> Result { + let (address, port, username, password): (String, u16, String, String) = + self.get_generic_params_input(); if address.is_empty() { return Err("Invalid host"); } @@ -99,17 +112,42 @@ impl AuthActivity { return Err("Invalid port"); } Ok(FileTransferParams { - address, - port, protocol, - username: match username.is_empty() { - true => None, - false => Some(username), - }, - password: match password.is_empty() { - true => None, - false => Some(password), - }, + params: ProtocolParams::Generic( + GenericProtocolParams::default() + .address(address) + .port(port) + .username(match username.is_empty() { + true => None, + false => Some(username), + }) + .password(match password.is_empty() { + true => None, + false => Some(password), + }), + ), + entry_directory: None, + }) + } + + /// ### collect_s3_host_params + /// + /// Get input values from fields or return an error if fields are invalid to work as aws s3 + pub(super) fn collect_s3_host_params( + &self, + protocol: FileTransferProtocol, + ) -> Result { + let (bucket, region, profile): (String, String, Option) = + self.get_s3_params_input(); + if bucket.is_empty() { + return Err("Invalid bucket"); + } + if region.is_empty() { + return Err("Invalid region"); + } + Ok(FileTransferParams { + protocol, + params: ProtocolParams::AwsS3(AwsS3Params::new(bucket, region, profile)), entry_directory: None, }) } diff --git a/src/ui/activities/auth/mod.rs b/src/ui/activities/auth/mod.rs index d956412..5fa1bfc 100644 --- a/src/ui/activities/auth/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -57,6 +57,9 @@ const COMPONENT_INPUT_PORT: &str = "INPUT_PORT"; const COMPONENT_INPUT_USERNAME: &str = "INPUT_USERNAME"; const COMPONENT_INPUT_PASSWORD: &str = "INPUT_PASSWORD"; const COMPONENT_INPUT_BOOKMARK_NAME: &str = "INPUT_BOOKMARK_NAME"; +const COMPONENT_INPUT_S3_BUCKET: &str = "INPUT_S3_BUCKET"; +const COMPONENT_INPUT_S3_REGION: &str = "INPUT_S3_REGION"; +const COMPONENT_INPUT_S3_PROFILE: &str = "INPUT_S3_PROFILE"; const COMPONENT_RADIO_PROTOCOL: &str = "RADIO_PROTOCOL"; const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT"; const COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK: &str = "RADIO_DELETE_BOOKMARK"; @@ -163,6 +166,16 @@ impl AuthActivity { fn theme(&self) -> &Theme { self.context().theme_provider().theme() } + + /// ### input_mask + /// + /// Get current input mask to show + fn input_mask(&self) -> InputMask { + match self.get_protocol() { + FileTransferProtocol::AwsS3 => InputMask::AwsS3, + _ => InputMask::Generic, + } + } } impl Activity for AuthActivity { @@ -261,3 +274,12 @@ impl Activity for AuthActivity { } } } + +/// ## InputMask +/// +/// Auth form input mask +#[derive(Eq, PartialEq)] +enum InputMask { + Generic, + AwsS3, +} diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index dd2275d..da9ed83 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -27,8 +27,9 @@ */ // locals use super::{ - AuthActivity, FileTransferProtocol, COMPONENT_BOOKMARKS_LIST, COMPONENT_INPUT_ADDR, + AuthActivity, FileTransferProtocol, InputMask, COMPONENT_BOOKMARKS_LIST, COMPONENT_INPUT_ADDR, COMPONENT_INPUT_BOOKMARK_NAME, COMPONENT_INPUT_PASSWORD, COMPONENT_INPUT_PORT, + COMPONENT_INPUT_S3_BUCKET, COMPONENT_INPUT_S3_PROFILE, COMPONENT_INPUT_S3_REGION, COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, COMPONENT_RADIO_BOOKMARK_DEL_RECENT, COMPONENT_RADIO_BOOKMARK_SAVE_PWD, COMPONENT_RADIO_PROTOCOL, COMPONENT_RADIO_QUIT, COMPONENT_RECENTS_LIST, COMPONENT_TEXT_ERROR, @@ -53,54 +54,80 @@ impl Update for AuthActivity { Some(msg) => match msg { // Focus ( DOWN ) (COMPONENT_RADIO_PROTOCOL, key) if key == &MSG_KEY_DOWN => { - // Give focus to port - self.view.active(COMPONENT_INPUT_ADDR); + // Give focus based on current mask + match self.input_mask() { + InputMask::Generic => self.view.active(COMPONENT_INPUT_ADDR), + InputMask::AwsS3 => self.view.active(COMPONENT_INPUT_S3_BUCKET), + }; None } + // -- generic mask (DOWN) (COMPONENT_INPUT_ADDR, key) if key == &MSG_KEY_DOWN => { - // Give focus to port self.view.active(COMPONENT_INPUT_PORT); None } (COMPONENT_INPUT_PORT, key) if key == &MSG_KEY_DOWN => { - // Give focus to port self.view.active(COMPONENT_INPUT_USERNAME); None } (COMPONENT_INPUT_USERNAME, key) if key == &MSG_KEY_DOWN => { - // Give focus to port self.view.active(COMPONENT_INPUT_PASSWORD); None } (COMPONENT_INPUT_PASSWORD, key) if key == &MSG_KEY_DOWN => { - // Give focus to port + self.view.active(COMPONENT_RADIO_PROTOCOL); + None + } + // -- s3 mask (DOWN) + (COMPONENT_INPUT_S3_BUCKET, key) if key == &MSG_KEY_DOWN => { + self.view.active(COMPONENT_INPUT_S3_REGION); + None + } + (COMPONENT_INPUT_S3_REGION, key) if key == &MSG_KEY_DOWN => { + self.view.active(COMPONENT_INPUT_S3_PROFILE); + None + } + (COMPONENT_INPUT_S3_PROFILE, key) if key == &MSG_KEY_DOWN => { self.view.active(COMPONENT_RADIO_PROTOCOL); None } // Focus ( UP ) + // -- generic (UP) (COMPONENT_INPUT_PASSWORD, key) if key == &MSG_KEY_UP => { - // Give focus to port self.view.active(COMPONENT_INPUT_USERNAME); None } (COMPONENT_INPUT_USERNAME, key) if key == &MSG_KEY_UP => { - // Give focus to port self.view.active(COMPONENT_INPUT_PORT); None } (COMPONENT_INPUT_PORT, key) if key == &MSG_KEY_UP => { - // Give focus to port self.view.active(COMPONENT_INPUT_ADDR); None } (COMPONENT_INPUT_ADDR, key) if key == &MSG_KEY_UP => { - // Give focus to port self.view.active(COMPONENT_RADIO_PROTOCOL); None } + // -- s3 (UP) + (COMPONENT_INPUT_S3_BUCKET, key) if key == &MSG_KEY_UP => { + self.view.active(COMPONENT_RADIO_PROTOCOL); + None + } + (COMPONENT_INPUT_S3_REGION, key) if key == &MSG_KEY_UP => { + self.view.active(COMPONENT_INPUT_S3_BUCKET); + None + } + (COMPONENT_INPUT_S3_PROFILE, key) if key == &MSG_KEY_UP => { + self.view.active(COMPONENT_INPUT_S3_REGION); + None + } (COMPONENT_RADIO_PROTOCOL, key) if key == &MSG_KEY_UP => { - // Give focus to port - self.view.active(COMPONENT_INPUT_PASSWORD); + // Give focus based on current mask + match self.input_mask() { + InputMask::Generic => self.view.active(COMPONENT_INPUT_PASSWORD), + InputMask::AwsS3 => self.view.active(COMPONENT_INPUT_S3_PROFILE), + }; None } // Protocol - On Change @@ -144,14 +171,20 @@ impl Update for AuthActivity { // Enter (COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => { self.load_bookmark(*idx); - // Give focus to input password - self.view.active(COMPONENT_INPUT_PASSWORD); + // Give focus to input password (or to protocol if not generic) + self.view.active(match self.input_mask() { + InputMask::Generic => COMPONENT_INPUT_PASSWORD, + InputMask::AwsS3 => COMPONENT_INPUT_S3_BUCKET, + }); None } (COMPONENT_RECENTS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => { self.load_recent(*idx); // Give focus to input password - self.view.active(COMPONENT_INPUT_PASSWORD); + self.view.active(match self.input_mask() { + InputMask::Generic => COMPONENT_INPUT_PASSWORD, + InputMask::AwsS3 => COMPONENT_INPUT_S3_BUCKET, + }); None } // Bookmark radio @@ -320,7 +353,7 @@ impl Update for AuthActivity { if key == &MSG_KEY_TAB => { // Give focus to address - self.view.active(COMPONENT_INPUT_ADDR); + self.view.active(COMPONENT_RADIO_PROTOCOL); None } // Any , go to bookmarks diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index ba3adc8..ee42c2e 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -26,7 +26,9 @@ * SOFTWARE. */ // Locals -use super::{AuthActivity, Context, FileTransferProtocol}; +use super::{AuthActivity, Context, FileTransferProtocol, InputMask}; +use crate::filetransfer::params::ProtocolParams; +use crate::filetransfer::FileTransferParams; use crate::ui::components::bookmark_list::{BookmarkList, BookmarkListPropsBuilder}; use crate::utils::ui::draw_area_in; // Ext @@ -109,7 +111,7 @@ impl AuthActivity { .with_inverted_color(Color::Black) .with_borders(Borders::ALL, BorderType::Rounded, protocol_color) .with_title("Protocol", Alignment::Left) - .with_options(&["SFTP", "SCP", "FTP", "FTPS"]) + .with_options(&["SFTP", "SCP", "FTP", "FTPS", "AWS S3"]) .with_value(Self::protocol_enum_to_opt(default_protocol)) .rewind(true) .build(), @@ -163,6 +165,39 @@ impl AuthActivity { .build(), )), ); + // Bucket + self.view.mount( + super::COMPONENT_INPUT_S3_BUCKET, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(addr_color) + .with_borders(Borders::ALL, BorderType::Rounded, addr_color) + .with_label("Bucket name", Alignment::Left) + .build(), + )), + ); + // Region + self.view.mount( + super::COMPONENT_INPUT_S3_REGION, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(port_color) + .with_borders(Borders::ALL, BorderType::Rounded, port_color) + .with_label("Region", Alignment::Left) + .build(), + )), + ); + // Profile + self.view.mount( + super::COMPONENT_INPUT_S3_PROFILE, + Box::new(Input::new( + InputPropsBuilder::default() + .with_foreground(username_color) + .with_borders(Borders::ALL, BorderType::Rounded, username_color) + .with_label("Profile", Alignment::Left) + .build(), + )), + ); // Version notice if let Some(version) = self .context() @@ -240,20 +275,43 @@ impl AuthActivity { let auth_chunks = Layout::default() .constraints( [ - Constraint::Length(1), // h1 - Constraint::Length(1), // h2 - Constraint::Length(1), // Version - Constraint::Length(3), // protocol - Constraint::Length(3), // host - Constraint::Length(3), // port - Constraint::Length(3), // username - Constraint::Length(3), // password - Constraint::Length(3), // footer + Constraint::Length(1), // h1 + Constraint::Length(1), // h2 + Constraint::Length(1), // Version + Constraint::Length(3), // protocol + Constraint::Length(self.input_mask_size()), // Input mask + Constraint::Length(3), // footer ] .as_ref(), ) .direction(Direction::Vertical) .split(chunks[0]); + // Input mask chunks + let input_mask = match self.input_mask() { + InputMask::AwsS3 => Layout::default() + .constraints( + [ + Constraint::Length(3), // bucket + Constraint::Length(3), // region + Constraint::Length(3), // profile + ] + .as_ref(), + ) + .direction(Direction::Vertical) + .split(auth_chunks[4]), + InputMask::Generic => Layout::default() + .constraints( + [ + Constraint::Length(3), // host + Constraint::Length(3), // port + Constraint::Length(3), // username + Constraint::Length(3), // password + ] + .as_ref(), + ) + .direction(Direction::Vertical) + .split(auth_chunks[4]), + }; // Create bookmark chunks let bookmark_chunks = Layout::default() .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) @@ -269,16 +327,29 @@ impl AuthActivity { .render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[2]); self.view .render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[3]); + // Render input mask + match self.input_mask() { + InputMask::AwsS3 => { + self.view + .render(super::COMPONENT_INPUT_S3_BUCKET, f, input_mask[0]); + self.view + .render(super::COMPONENT_INPUT_S3_REGION, f, input_mask[1]); + self.view + .render(super::COMPONENT_INPUT_S3_PROFILE, f, input_mask[2]); + } + InputMask::Generic => { + self.view + .render(super::COMPONENT_INPUT_ADDR, f, input_mask[0]); + self.view + .render(super::COMPONENT_INPUT_PORT, f, input_mask[1]); + self.view + .render(super::COMPONENT_INPUT_USERNAME, f, input_mask[2]); + self.view + .render(super::COMPONENT_INPUT_PASSWORD, f, input_mask[3]); + } + } self.view - .render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[4]); - self.view - .render(super::COMPONENT_INPUT_PORT, f, auth_chunks[5]); - self.view - .render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[6]); - self.view - .render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[7]); - self.view - .render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[8]); + .render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[5]); // Bookmark chunks self.view .render(super::COMPONENT_BOOKMARKS_LIST, f, bookmark_chunks[0]); @@ -388,19 +459,13 @@ impl AuthActivity { .bookmarks_list .iter() .map(|x| { - let entry: (String, u16, FileTransferProtocol, String, _) = self - .bookmarks_client - .as_ref() - .unwrap() - .get_bookmark(x) - .unwrap(); - format!( - "{} ({}://{}@{}:{})", + Self::fmt_bookmark( x, - entry.2.to_string().to_lowercase(), - entry.3, - entry.0, - entry.1 + self.bookmarks_client + .as_ref() + .unwrap() + .get_bookmark(x) + .unwrap(), ) }) .collect(); @@ -426,19 +491,12 @@ impl AuthActivity { .recents_list .iter() .map(|x| { - let entry: (String, u16, FileTransferProtocol, String) = self - .bookmarks_client - .as_ref() - .unwrap() - .get_recent(x) - .unwrap(); - - format!( - "{}://{}@{}:{}", - entry.2.to_string().to_lowercase(), - entry.3, - entry.0, - entry.1 + Self::fmt_recent( + self.bookmarks_client + .as_ref() + .unwrap() + .get_recent(x) + .unwrap(), ) }) .collect(); @@ -743,16 +801,32 @@ impl AuthActivity { self.view.umount(super::COMPONENT_TEXT_NEW_VERSION_NOTES); } - /// ### get_input + /// ### get_protocol + /// + /// Get protocol from view + pub(super) fn get_protocol(&self) -> FileTransferProtocol { + self.get_input_protocol() + } + + /// ### get_generic_params /// /// Collect input values from view - pub(super) fn get_input(&self) -> (String, u16, FileTransferProtocol, String, String) { + pub(super) fn get_generic_params_input(&self) -> (String, u16, String, String) { let addr: String = self.get_input_addr(); let port: u16 = self.get_input_port(); - let protocol: FileTransferProtocol = self.get_input_protocol(); let username: String = self.get_input_username(); let password: String = self.get_input_password(); - (addr, port, protocol, username, password) + (addr, port, username, password) + } + + /// ### get_s3_params_input + /// + /// Collect s3 input values from view + pub(super) fn get_s3_params_input(&self) -> (String, String, Option) { + let bucket: String = self.get_input_s3_bucket(); + let region: String = self.get_input_s3_region(); + let profile: Option = self.get_input_s3_profile(); + (bucket, region, profile) } pub(super) fn get_input_addr(&self) -> String { @@ -792,4 +866,75 @@ impl AuthActivity { _ => String::new(), } } + + pub(super) fn get_input_s3_bucket(&self) -> String { + match self.view.get_state(super::COMPONENT_INPUT_S3_BUCKET) { + Some(Payload::One(Value::Str(x))) => x, + _ => String::new(), + } + } + + pub(super) fn get_input_s3_region(&self) -> String { + match self.view.get_state(super::COMPONENT_INPUT_S3_REGION) { + Some(Payload::One(Value::Str(x))) => x, + _ => String::new(), + } + } + + pub(super) fn get_input_s3_profile(&self) -> Option { + match self.view.get_state(super::COMPONENT_INPUT_S3_PROFILE) { + Some(Payload::One(Value::Str(x))) => match x.is_empty() { + true => None, + false => Some(x), + }, + _ => None, + } + } + + /// ### input_mask_size + /// + /// Returns the input mask size based on current input mask + pub(super) fn input_mask_size(&self) -> u16 { + match self.input_mask() { + InputMask::AwsS3 => 9, + InputMask::Generic => 12, + } + } + + /// ### fmt_bookmark + /// + /// Format bookmark to display on ui + fn fmt_bookmark(name: &str, b: FileTransferParams) -> String { + let addr: String = Self::fmt_recent(b); + format!("{} ({})", name, addr) + } + + /// ### fmt_recent + /// + /// Format recent connection to display on ui + fn fmt_recent(b: FileTransferParams) -> String { + let protocol: String = b.protocol.to_string().to_lowercase(); + match b.params { + ProtocolParams::AwsS3(s3) => { + let profile: String = match s3.profile { + Some(p) => format!("[{}]", p), + None => String::default(), + }; + format!( + "{}://{} ({}) {}", + protocol, s3.bucket_name, s3.region, profile + ) + } + ProtocolParams::Generic(params) => { + let username: String = match params.username { + None => String::default(), + Some(u) => format!("{}@", u), + }; + format!( + "{}://{}{}:{}", + protocol, username, params.address, params.port + ) + } + } + } } diff --git a/src/ui/activities/filetransfer/actions/copy.rs b/src/ui/activities/filetransfer/actions/copy.rs index 3970603..2d67963 100644 --- a/src/ui/activities/filetransfer/actions/copy.rs +++ b/src/ui/activities/filetransfer/actions/copy.rs @@ -125,7 +125,7 @@ impl FileTransferActivity { Err(err) => match err.kind() { FileTransferErrorType::UnsupportedFeature => { // If copy is not supported, perform the tricky copy - self.tricky_copy(entry, dest); + let _ = self.tricky_copy(entry, dest); } _ => self.log_and_alert( LogLevel::Error, @@ -143,7 +143,7 @@ impl FileTransferActivity { /// ### tricky_copy /// /// Tricky copy will be used whenever copy command is not available on remote host - fn tricky_copy(&mut self, entry: FsEntry, dest: &Path) { + pub(super) fn tricky_copy(&mut self, entry: FsEntry, dest: &Path) -> Result<(), String> { // NOTE: VERY IMPORTANT; wait block must be umounted or something really bad will happen self.umount_wait(); // match entry @@ -157,7 +157,7 @@ impl FileTransferActivity { LogLevel::Error, format!("Copy failed: could not create temporary file: {}", err), ); - return; + return Err(String::from("Could not create temporary file")); } }; // Download file @@ -170,7 +170,7 @@ impl FileTransferActivity { LogLevel::Error, format!("Copy failed: could not download to temporary file: {}", err), ); - return; + return Err(err); } // Get local fs entry let tmpfile_entry: FsFile = match self.host.stat(tmpfile.path()) { @@ -184,7 +184,7 @@ impl FileTransferActivity { err ), ); - return; + return Err(err.to_string()); } }; // Upload file to destination @@ -202,8 +202,9 @@ impl FileTransferActivity { err ), ); - return; + return Err(err); } + Ok(()) } FsEntry::Directory(_) => { let tempdir: tempfile::TempDir = match tempfile::TempDir::new() { @@ -213,7 +214,7 @@ impl FileTransferActivity { LogLevel::Error, format!("Copy failed: could not create temporary directory: {}", err), ); - return; + return Err(err.to_string()); } }; // Get path of dest @@ -227,7 +228,7 @@ impl FileTransferActivity { LogLevel::Error, format!("Copy failed: failed to download file: {}", err), ); - return; + return Err(err); } // Stat dir let tempdir_entry: FsEntry = match self.host.stat(tempdir_path.as_path()) { @@ -241,7 +242,7 @@ impl FileTransferActivity { err ), ); - return; + return Err(err.to_string()); } }; // Upload to destination @@ -255,8 +256,9 @@ impl FileTransferActivity { LogLevel::Error, format!("Copy failed: failed to send file: {}", err), ); - return; + return Err(err); } + Ok(()) } } } diff --git a/src/ui/activities/filetransfer/actions/newfile.rs b/src/ui/activities/filetransfer/actions/newfile.rs index ac65f79..fe68350 100644 --- a/src/ui/activities/filetransfer/actions/newfile.rs +++ b/src/ui/activities/filetransfer/actions/newfile.rs @@ -27,6 +27,7 @@ */ // locals use super::{FileTransferActivity, FsEntry, LogLevel}; +use std::fs::File; use std::path::PathBuf; impl FileTransferActivity { @@ -99,24 +100,29 @@ impl FileTransferActivity { }; if let FsEntry::File(local_file) = local_file { // Create file - match self.client.send_file(&local_file, file_path.as_path()) { + let reader = Box::new(match File::open(tfile.path()) { + Ok(f) => f, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Could not open tempfile: {}", err), + ); + return; + } + }); + match self + .client + .send_file_wno_stream(&local_file, file_path.as_path(), reader) + { Err(err) => self.log_and_alert( LogLevel::Error, format!("Could not create file \"{}\": {}", file_path.display(), err), ), - Ok(writer) => { - // Finalize write - if let Err(err) = self.client.on_sent(writer) { - self.log_and_alert( - LogLevel::Warn, - format!("Could not finalize file: {}", err), - ); - } else { - self.log( - LogLevel::Info, - format!("Created file \"{}\"", file_path.display()), - ); - } + Ok(_) => { + self.log( + LogLevel::Info, + format!("Created file \"{}\"", file_path.display()), + ); // Reload files self.reload_remote_dir(); } diff --git a/src/ui/activities/filetransfer/actions/rename.rs b/src/ui/activities/filetransfer/actions/rename.rs index 80f3fd5..31aaad5 100644 --- a/src/ui/activities/filetransfer/actions/rename.rs +++ b/src/ui/activities/filetransfer/actions/rename.rs @@ -27,6 +27,7 @@ */ // locals use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; +use crate::filetransfer::FileTransferErrorType; use std::path::{Path, PathBuf}; impl FileTransferActivity { @@ -114,6 +115,9 @@ impl FileTransferActivity { ), ); } + Err(err) if err.kind() == FileTransferErrorType::UnsupportedFeature => { + self.tricky_move(entry, dest); + } Err(err) => self.log_and_alert( LogLevel::Error, format!( @@ -125,4 +129,41 @@ impl FileTransferActivity { ), } } + + /// ### tricky_move + /// + /// Tricky move will be used whenever copy command is not available on remote host. + /// It basically uses the tricky_copy function, then it just deletes the previous entry (`entry`) + fn tricky_move(&mut self, entry: &FsEntry, dest: &Path) { + debug!( + "Using tricky-move to move entry {} to {}", + entry.get_abs_path().display(), + dest.display() + ); + if self.tricky_copy(entry.clone(), dest).is_ok() { + // Delete remote existing entry + debug!("Tricky-copy worked; removing existing remote entry"); + match self.client.remove(entry) { + Ok(_) => self.log( + LogLevel::Info, + format!( + "Moved \"{}\" to \"{}\"", + entry.get_abs_path().display(), + dest.display() + ), + ), + Err(err) => self.log_and_alert( + LogLevel::Error, + format!( + "Copied \"{}\" to \"{}\"; but failed to remove src: {}", + entry.get_abs_path().display(), + dest.display(), + err + ), + ), + } + } else { + error!("Tricky move aborted due to tricky-copy failure"); + } + } } diff --git a/src/ui/activities/filetransfer/lib/transfer.rs b/src/ui/activities/filetransfer/lib/transfer.rs index 2fc7429..2816f51 100644 --- a/src/ui/activities/filetransfer/lib/transfer.rs +++ b/src/ui/activities/filetransfer/lib/transfer.rs @@ -140,6 +140,10 @@ impl ProgressStates { /// /// Calculate progress in a range between 0.0 to 1.0 pub fn calc_progress(&self) -> f64 { + // Prevent dividing by 0 + if self.total == 0 { + return 0.0; + } let prog: f64 = (self.written as f64) / (self.total as f64); match prog > 1.0 { true => 1.0, @@ -238,6 +242,11 @@ mod test { // Check if terminated at started states.started = Instant::now(); assert_eq!(states.calc_bytes_per_second(), 1024); + // Divide by zero + let states: ProgressStates = ProgressStates::default(); + assert_eq!(states.total, 0); + assert_eq!(states.written, 0); + assert_eq!(states.calc_progress(), 0.0); } #[test] diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 60360d7..7fdce4f 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -23,6 +23,7 @@ */ // Locals use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord}; +use crate::filetransfer::ProtocolParams; use crate::system::environment; use crate::system::sshkey_storage::SshKeyStorage; use crate::utils::path; @@ -134,4 +135,15 @@ impl FileTransferActivity { pub(super) fn remote_to_abs_path(&self, path: &Path) -> PathBuf { path::absolutize(self.remote().wrkdir.as_path(), path) } + + /// ### get_remote_hostname + /// + /// Get remote hostname + pub(super) fn get_remote_hostname(&self) -> String { + let ft_params = self.context().ft_params().unwrap(); + match &ft_params.params { + ProtocolParams::Generic(params) => params.address.clone(), + ProtocolParams::AwsS3(params) => params.bucket_name.clone(), + } + } } diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index 3d3a655..d768ed8 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -36,10 +36,8 @@ pub(self) mod view; // locals use super::{Activity, Context, ExitReason}; use crate::config::themes::Theme; -use crate::filetransfer::ftp_transfer::FtpFileTransfer; -use crate::filetransfer::scp_transfer::ScpFileTransfer; -use crate::filetransfer::sftp_transfer::SftpFileTransfer; -use crate::filetransfer::{FileTransfer, FileTransferProtocol}; +use crate::filetransfer::{FileTransfer, FileTransferProtocol, ProtocolParams}; +use crate::filetransfer::{FtpFileTransfer, S3FileTransfer, ScpFileTransfer, SftpFileTransfer}; use crate::fs::explorer::FileExplorer; use crate::fs::FsEntry; use crate::host::Localhost; @@ -155,6 +153,7 @@ impl FileTransferActivity { FileTransferProtocol::Scp => { Box::new(ScpFileTransfer::new(Self::make_ssh_storage(&config_client))) } + FileTransferProtocol::AwsS3 => Box::new(S3FileTransfer::default()), }, browser: Browser::new(&config_client), log_records: VecDeque::with_capacity(256), // 256 events is enough I guess @@ -237,6 +236,28 @@ impl FileTransferActivity { fn theme(&self) -> &Theme { self.context().theme_provider().theme() } + + /// ### get_connection_msg + /// + /// Get connection message to show to client + fn get_connection_msg(params: &ProtocolParams) -> String { + match params { + ProtocolParams::Generic(params) => { + info!( + "Client is not connected to remote; connecting to {}:{}", + params.address, params.port + ); + format!("Connecting to {}:{}…", params.address, params.port) + } + ProtocolParams::AwsS3(params) => { + info!( + "Client is not connected to remote; connecting to {} ({})", + params.bucket_name, params.region + ); + format!("Connecting to {}…", params.bucket_name) + } + } + } } /** @@ -290,12 +311,9 @@ impl Activity for FileTransferActivity { } // Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error) if !self.client.is_connected() && self.view.get_props(COMPONENT_TEXT_FATAL).is_none() { - let params = self.context().ft_params().unwrap(); - info!( - "Client is not connected to remote; connecting to {}:{}", - params.address, params.port - ); - let msg: String = format!("Connecting to {}:{}…", params.address, params.port); + let ftparams = self.context().ft_params().unwrap(); + // print params + let msg: String = Self::get_connection_msg(&ftparams.params); // Set init state to connecting popup self.mount_wait(msg.as_str()); // Force ui draw diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index a9a4eb7..cb4a0ff 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -34,6 +34,7 @@ use crate::utils::fmt::fmt_millis; // Ext use bytesize::ByteSize; +use std::fs::File; use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -76,22 +77,20 @@ impl FileTransferActivity { /// /// Connect to remote pub(super) fn connect(&mut self) { - let params = self.context().ft_params().unwrap().clone(); - let addr: String = params.address.clone(); - let entry_dir: Option = params.entry_directory.clone(); + let ft_params = self.context().ft_params().unwrap().clone(); + let entry_dir: Option = ft_params.entry_directory.clone(); // Connect to remote - match self.client.connect( - params.address, - params.port, - params.username, - params.password, - ) { + match self.client.connect(&ft_params.params) { Ok(welcome) => { if let Some(banner) = welcome { // Log welcome self.log( LogLevel::Info, - format!("Established connection with '{}': \"{}\"", addr, banner), + format!( + "Established connection with '{}': \"{}\"", + self.get_remote_hostname(), + banner + ), ); } // Try to change directory to entry directory @@ -121,8 +120,7 @@ impl FileTransferActivity { /// /// disconnect from remote pub(super) fn disconnect(&mut self) { - let params = self.context().ft_params().unwrap(); - let msg: String = format!("Disconnecting from {}…", params.address); + let msg: String = format!("Disconnecting from {}…", self.get_remote_hostname()); // Show popup disconnecting self.mount_wait(msg.as_str()); // Disconnect @@ -442,103 +440,165 @@ impl FileTransferActivity { // Upload file // Try to open local file match self.host.open_file_read(local.abs_path.as_path()) { - Ok(mut fhnd) => match self.client.send_file(local, remote) { - Ok(mut rhnd) => { - // Write file - let file_size: usize = - fhnd.seek(std::io::SeekFrom::End(0)).unwrap_or(0) as usize; - // Init transfer - self.transfer.partial.init(file_size); - // rewind - if let Err(err) = fhnd.seek(std::io::SeekFrom::Start(0)) { - return Err(TransferErrorReason::CouldNotRewind(err)); - } - // Write remote file - let mut total_bytes_written: usize = 0; - let mut last_progress_val: f64 = 0.0; - let mut last_input_event_fetch: Option = None; - // While the entire file hasn't been completely written, - // Or filetransfer has been aborted - while total_bytes_written < file_size && !self.transfer.aborted() { - // Handle input events (each 500ms) or if never fetched before - if last_input_event_fetch.is_none() - || last_input_event_fetch - .unwrap_or_else(Instant::now) - .elapsed() - .as_millis() - >= 500 - { - // Read events - self.read_input_event(); - // Reset instant - last_input_event_fetch = Some(Instant::now()); - } - // Read till you can - let mut buffer: [u8; 65536] = [0; 65536]; - let delta: usize = match fhnd.read(&mut buffer) { - Ok(bytes_read) => { - total_bytes_written += bytes_read; - if bytes_read == 0 { - continue; - } else { - let mut delta: usize = 0; - while delta < bytes_read { - // Write bytes - match rhnd.write(&buffer[delta..bytes_read]) { - Ok(bytes) => { - delta += bytes; - } - Err(err) => { - return Err(TransferErrorReason::RemoteIoError( - err, - )); - } - } - } - delta + Ok(fhnd) => match self.client.send_file(local, remote) { + Ok(rhnd) => { + self.filetransfer_send_one_with_stream(local, remote, file_name, fhnd, rhnd) + } + Err(err) if err.kind() == FileTransferErrorType::UnsupportedFeature => { + self.filetransfer_send_one_wno_stream(local, remote, file_name, fhnd) + } + Err(err) => Err(TransferErrorReason::FileTransferError(err)), + }, + Err(err) => Err(TransferErrorReason::HostError(err)), + } + } + + /// ### filetransfer_send_one_with_stream + /// + /// Send file to remote using stream + fn filetransfer_send_one_with_stream( + &mut self, + local: &FsFile, + remote: &Path, + file_name: String, + mut reader: File, + mut writer: Box, + ) -> Result<(), TransferErrorReason> { + // Write file + let file_size: usize = reader.seek(std::io::SeekFrom::End(0)).unwrap_or(0) as usize; + // Init transfer + self.transfer.partial.init(file_size); + // rewind + if let Err(err) = reader.seek(std::io::SeekFrom::Start(0)) { + return Err(TransferErrorReason::CouldNotRewind(err)); + } + // Write remote file + let mut total_bytes_written: usize = 0; + let mut last_progress_val: f64 = 0.0; + let mut last_input_event_fetch: Option = None; + // While the entire file hasn't been completely written, + // Or filetransfer has been aborted + while total_bytes_written < file_size && !self.transfer.aborted() { + // Handle input events (each 500ms) or if never fetched before + if last_input_event_fetch.is_none() + || last_input_event_fetch + .unwrap_or_else(Instant::now) + .elapsed() + .as_millis() + >= 500 + { + // Read events + self.read_input_event(); + // Reset instant + last_input_event_fetch = Some(Instant::now()); + } + // Read till you can + let mut buffer: [u8; 65536] = [0; 65536]; + let delta: usize = match reader.read(&mut buffer) { + Ok(bytes_read) => { + total_bytes_written += bytes_read; + if bytes_read == 0 { + continue; + } else { + let mut delta: usize = 0; + while delta < bytes_read { + // Write bytes + match writer.write(&buffer[delta..bytes_read]) { + Ok(bytes) => { + delta += bytes; + } + Err(err) => { + return Err(TransferErrorReason::RemoteIoError(err)); } } - Err(err) => { - return Err(TransferErrorReason::LocalIoError(err)); - } - }; - // Increase progress - self.transfer.partial.update_progress(delta); - self.transfer.full.update_progress(delta); - // Draw only if a significant progress has been made (performance improvement) - if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { - // Draw - self.update_progress_bar(format!("Uploading \"{}\"…", file_name)); - self.view(); - last_progress_val = self.transfer.partial.calc_progress(); } + delta } - // Finalize stream - if let Err(err) = self.client.on_sent(rhnd) { - self.log( - LogLevel::Warn, - format!("Could not finalize remote stream: \"{}\"", err), - ); - } - // if upload was abrupted, return error - if self.transfer.aborted() { - return Err(TransferErrorReason::Abrupted); - } - self.log( - LogLevel::Info, - format!( - "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", - local.abs_path.display(), - remote.display(), - fmt_millis(self.transfer.partial.started().elapsed()), - ByteSize(self.transfer.partial.calc_bytes_per_second()), - ), - ); } - Err(err) => return Err(TransferErrorReason::FileTransferError(err)), - }, - Err(err) => return Err(TransferErrorReason::HostError(err)), + Err(err) => { + return Err(TransferErrorReason::LocalIoError(err)); + } + }; + // Increase progress + self.transfer.partial.update_progress(delta); + self.transfer.full.update_progress(delta); + // Draw only if a significant progress has been made (performance improvement) + if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { + // Draw + self.update_progress_bar(format!("Uploading \"{}\"…", file_name)); + self.view(); + last_progress_val = self.transfer.partial.calc_progress(); + } } + // Finalize stream + if let Err(err) = self.client.on_sent(writer) { + self.log( + LogLevel::Warn, + format!("Could not finalize remote stream: \"{}\"", err), + ); + } + // if upload was abrupted, return error + if self.transfer.aborted() { + return Err(TransferErrorReason::Abrupted); + } + self.log( + LogLevel::Info, + format!( + "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", + local.abs_path.display(), + remote.display(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), + ); + Ok(()) + } + + /// ### filetransfer_send_one_wno_stream + /// + /// Send an `FsFile` to remote without using streams. + fn filetransfer_send_one_wno_stream( + &mut self, + local: &FsFile, + remote: &Path, + file_name: String, + mut reader: File, + ) -> Result<(), TransferErrorReason> { + // Write file + let file_size: usize = reader.seek(std::io::SeekFrom::End(0)).unwrap_or(0) as usize; + // Init transfer + self.transfer.partial.init(file_size); + // rewind + if let Err(err) = reader.seek(std::io::SeekFrom::Start(0)) { + return Err(TransferErrorReason::CouldNotRewind(err)); + } + // Draw before + self.update_progress_bar(format!("Uploading \"{}\"…", file_name)); + self.view(); + // Send file + if let Err(err) = self + .client + .send_file_wno_stream(local, remote, Box::new(reader)) + { + return Err(TransferErrorReason::FileTransferError(err)); + } + // Set transfer size ok + self.transfer.partial.update_progress(file_size); + self.transfer.full.update_progress(file_size); + // Draw again after + self.update_progress_bar(format!("Uploading \"{}\"…", file_name)); + self.view(); + // log and return Ok + self.log( + LogLevel::Info, + format!( + "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", + local.abs_path.display(), + remote.display(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), + ); Ok(()) } @@ -796,120 +856,187 @@ impl FileTransferActivity { ) -> Result<(), TransferErrorReason> { // Try to open local file match self.host.open_file_write(local) { - Ok(mut local_file) => { + Ok(local_file) => { // Download file from remote match self.client.recv_file(remote) { - Ok(mut rhnd) => { - let mut total_bytes_written: usize = 0; - // Init transfer - self.transfer.partial.init(remote.size); - // Write local file - let mut last_progress_val: f64 = 0.0; - let mut last_input_event_fetch: Option = None; - // While the entire file hasn't been completely read, - // Or filetransfer has been aborted - while total_bytes_written < remote.size && !self.transfer.aborted() { - // Handle input events (each 500 ms) or is None - if last_input_event_fetch.is_none() - || last_input_event_fetch - .unwrap_or_else(Instant::now) - .elapsed() - .as_millis() - >= 500 - { - // Read events - self.read_input_event(); - // Reset instant - last_input_event_fetch = Some(Instant::now()); - } - // Read till you can - let mut buffer: [u8; 65536] = [0; 65536]; - let delta: usize = match rhnd.read(&mut buffer) { - Ok(bytes_read) => { - total_bytes_written += bytes_read; - if bytes_read == 0 { - continue; - } else { - let mut delta: usize = 0; - while delta < bytes_read { - // Write bytes - match local_file.write(&buffer[delta..bytes_read]) { - Ok(bytes) => delta += bytes, - Err(err) => { - return Err(TransferErrorReason::LocalIoError( - err, - )); - } - } - } - delta - } - } - Err(err) => { - return Err(TransferErrorReason::RemoteIoError(err)); - } - }; - // Set progress - self.transfer.partial.update_progress(delta); - self.transfer.full.update_progress(delta); - // Draw only if a significant progress has been made (performance improvement) - if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { - // Draw - self.update_progress_bar(format!("Downloading \"{}\"", file_name)); - self.view(); - last_progress_val = self.transfer.partial.calc_progress(); - } - } - // Finalize stream - if let Err(err) = self.client.on_recv(rhnd) { - self.log( - LogLevel::Warn, - format!("Could not finalize remote stream: \"{}\"", err), - ); - } - // If download was abrupted, return Error - if self.transfer.aborted() { - return Err(TransferErrorReason::Abrupted); - } - // Apply file mode to file - #[cfg(any( - target_family = "unix", - target_os = "macos", - target_os = "linux" - ))] - if let Some((owner, group, others)) = remote.unix_pex { - if let Err(err) = self - .host - .chmod(local, (owner.as_byte(), group.as_byte(), others.as_byte())) - { - self.log( - LogLevel::Error, - format!( - "Could not apply file mode {:?} to \"{}\": {}", - (owner.as_byte(), group.as_byte(), others.as_byte()), - local.display(), - err - ), - ); - } - } - // Log - self.log( - LogLevel::Info, - format!( - "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", - remote.abs_path.display(), - local.display(), - fmt_millis(self.transfer.partial.started().elapsed()), - ByteSize(self.transfer.partial.calc_bytes_per_second()), - ), - ); + Ok(rhnd) => self.filetransfer_recv_one_with_stream( + local, remote, file_name, rhnd, local_file, + ), + Err(err) if err.kind() == FileTransferErrorType::UnsupportedFeature => { + self.filetransfer_recv_one_wno_stream(local, remote, file_name) } - Err(err) => return Err(TransferErrorReason::FileTransferError(err)), + Err(err) => Err(TransferErrorReason::FileTransferError(err)), } } - Err(err) => return Err(TransferErrorReason::HostError(err)), + Err(err) => Err(TransferErrorReason::HostError(err)), } + } + + /// ### filetransfer_recv_one_with_stream + /// + /// Receive an `FsEntry` from remote using stream + fn filetransfer_recv_one_with_stream( + &mut self, + local: &Path, + remote: &FsFile, + file_name: String, + mut reader: Box, + mut writer: File, + ) -> Result<(), TransferErrorReason> { + let mut total_bytes_written: usize = 0; + // Init transfer + self.transfer.partial.init(remote.size); + // Write local file + let mut last_progress_val: f64 = 0.0; + let mut last_input_event_fetch: Option = None; + // While the entire file hasn't been completely read, + // Or filetransfer has been aborted + while total_bytes_written < remote.size && !self.transfer.aborted() { + // Handle input events (each 500 ms) or is None + if last_input_event_fetch.is_none() + || last_input_event_fetch + .unwrap_or_else(Instant::now) + .elapsed() + .as_millis() + >= 500 + { + // Read events + self.read_input_event(); + // Reset instant + last_input_event_fetch = Some(Instant::now()); + } + // Read till you can + let mut buffer: [u8; 65536] = [0; 65536]; + let delta: usize = match reader.read(&mut buffer) { + Ok(bytes_read) => { + total_bytes_written += bytes_read; + if bytes_read == 0 { + continue; + } else { + let mut delta: usize = 0; + while delta < bytes_read { + // Write bytes + match writer.write(&buffer[delta..bytes_read]) { + Ok(bytes) => delta += bytes, + Err(err) => { + return Err(TransferErrorReason::LocalIoError(err)); + } + } + } + delta + } + } + Err(err) => { + return Err(TransferErrorReason::RemoteIoError(err)); + } + }; + // Set progress + self.transfer.partial.update_progress(delta); + self.transfer.full.update_progress(delta); + // Draw only if a significant progress has been made (performance improvement) + if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { + // Draw + self.update_progress_bar(format!("Downloading \"{}\"", file_name)); + self.view(); + last_progress_val = self.transfer.partial.calc_progress(); + } + } + // Finalize stream + if let Err(err) = self.client.on_recv(reader) { + self.log( + LogLevel::Warn, + format!("Could not finalize remote stream: \"{}\"", err), + ); + } + // If download was abrupted, return Error + if self.transfer.aborted() { + return Err(TransferErrorReason::Abrupted); + } + // Apply file mode to file + #[cfg(target_family = "unix")] + if let Some((owner, group, others)) = remote.unix_pex { + if let Err(err) = self + .host + .chmod(local, (owner.as_byte(), group.as_byte(), others.as_byte())) + { + self.log( + LogLevel::Error, + format!( + "Could not apply file mode {:?} to \"{}\": {}", + (owner.as_byte(), group.as_byte(), others.as_byte()), + local.display(), + err + ), + ); + } + } + // Log + self.log( + LogLevel::Info, + format!( + "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", + remote.abs_path.display(), + local.display(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), + ); + Ok(()) + } + + /// ### filetransfer_recv_one_with_stream + /// + /// Receive an `FsEntry` from remote without using stream + fn filetransfer_recv_one_wno_stream( + &mut self, + local: &Path, + remote: &FsFile, + file_name: String, + ) -> Result<(), TransferErrorReason> { + // Init transfer + self.transfer.partial.init(remote.size); + // Draw before transfer + self.update_progress_bar(format!("Downloading \"{}\"", file_name)); + self.view(); + // recv wno stream + if let Err(err) = self.client.recv_file_wno_stream(remote, local) { + return Err(TransferErrorReason::FileTransferError(err)); + } + // Update progress at the end + self.transfer.partial.update_progress(remote.size); + self.transfer.full.update_progress(remote.size); + // Draw after transfer + self.update_progress_bar(format!("Downloading \"{}\"", file_name)); + self.view(); + // Apply file mode to file + #[cfg(target_family = "unix")] + if let Some((owner, group, others)) = remote.unix_pex { + if let Err(err) = self + .host + .chmod(local, (owner.as_byte(), group.as_byte(), others.as_byte())) + { + self.log( + LogLevel::Error, + format!( + "Could not apply file mode {:?} to \"{}\": {}", + (owner.as_byte(), group.as_byte(), others.as_byte()), + local.display(), + err + ), + ); + } + } + // Log + self.log( + LogLevel::Info, + format!( + "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", + remote.abs_path.display(), + local.display(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), + ); Ok(()) } diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 3dc40fc..39fe2a2 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -810,14 +810,14 @@ impl FileTransferActivity { .store() .get_unsigned(super::STORAGE_EXPLORER_WIDTH) .unwrap_or(256); - let params = self.context().ft_params().unwrap(); + let hostname = self.get_remote_hostname(); let hostname: String = format!( "{}:{} ", - params.address, + hostname, fmt_path_elide_ex( self.remote().wrkdir.as_path(), width, - params.address.len() + 3 // 3 because of '/…/' + hostname.len() + 3 // 3 because of '/…/' ) ); let files: Vec = self diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs index 218c8fb..fa195cb 100644 --- a/src/ui/activities/setup/view/setup.rs +++ b/src/ui/activities/setup/view/setup.rs @@ -81,12 +81,7 @@ impl SetupActivity { .with_inverted_color(Color::Black) .with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan) .with_title("Default file transfer protocol", Alignment::Left) - .with_options(&[ - String::from("SFTP"), - String::from("SCP"), - String::from("FTP"), - String::from("FTPS"), - ]) + .with_options(&["SFTP", "SCP", "FTP", "FTPS", "AWS S3"]) .rewind(true) .build(), )), @@ -265,6 +260,7 @@ impl SetupActivity { FileTransferProtocol::Scp => 1, FileTransferProtocol::Ftp(false) => 2, FileTransferProtocol::Ftp(true) => 3, + FileTransferProtocol::AwsS3 => 4, }; let props = RadioPropsBuilder::from(props).with_value(protocol).build(); let _ = self @@ -334,6 +330,7 @@ impl SetupActivity { 1 => FileTransferProtocol::Scp, 2 => FileTransferProtocol::Ftp(false), 3 => FileTransferProtocol::Ftp(true), + 4 => FileTransferProtocol::AwsS3, _ => FileTransferProtocol::Sftp, }; self.config_mut().set_default_protocol(protocol); diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 7359b44..5b3f2ce 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -26,7 +26,10 @@ * SOFTWARE. */ // Locals -use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; +use crate::filetransfer::{ + params::{AwsS3Params, GenericProtocolParams, ProtocolParams}, + FileTransferParams, FileTransferProtocol, +}; #[cfg(not(test))] // NOTE: don't use configuration during tests use crate::system::config_client::ConfigClient; #[cfg(not(test))] // NOTE: don't use configuration during tests @@ -43,15 +46,34 @@ use tuirealm::tui::style::Color; // Regex lazy_static! { + + /** + * This regex matches the protocol used as option + * Regex matches: + * - group 1: Some(protocol) | None + * - group 2: Some(other args) + */ + static ref REMOTE_OPT_PROTOCOL_REGEX: Regex = Regex::new(r"(?:([a-z0-9]+)://)?(?:(.+))").unwrap(); + /** * Regex matches: - * - group 1: Some(protocol) | None - * - group 2: Some(user) | None - * - group 3: Address - * - group 4: Some(port) | None - * - group 5: Some(path) | None + * - group 1: Some(user) | None + * - group 2: Address + * - group 3: Some(port) | None + * - group 4: Some(path) | None */ - static ref REMOTE_OPT_REGEX: Regex = Regex::new(r"(?:([a-z]+)://)?(?:([^@]+)@)?(?:([^:]+))(?::((?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])))?(?::([^:]+))?").ok().unwrap(); + static ref REMOTE_GENERIC_OPT_REGEX: Regex = Regex::new(r"(?:([^@]+)@)?(?:([^:]+))(?::((?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])))?(?::([^:]+))?").ok().unwrap(); + + /** + * Regex matches: + * - group 1: Bucket + * - group 2: Region + * - group 3: Some(profile) | None + * - group 4: Some(path) | None + */ + static ref REMOTE_S3_OPT_REGEX: Regex = Regex::new(r"(?:([^@]+)@)(?:([^:]+))(?::([a-zA-Z0-9][^:]+))?(?::([^:]+))?").unwrap(); + + /** * Regex matches: * - group 1: Version @@ -75,6 +97,8 @@ lazy_static! { static ref COLOR_RGB_REGEX: Regex = Regex::new(r"^(rgb)?\(?([01]?\d\d?|2[0-4]\d|25[0-5])(\W+)([01]?\d\d?|2[0-4]\d|25[0-5])\W+(([01]?\d\d?|2[0-4]\d|25[0-5])\)?)").unwrap(); } +// -- remote opts + /// ### parse_remote_opt /// /// Parse remote option string. Returns in case of success a RemoteOptions struct @@ -93,10 +117,10 @@ lazy_static! { /// - sftp://172.26.104.1:4022 /// - sftp://172.26.104.1 /// - ... -pub fn parse_remote_opt(remote: &str) -> Result { +pub fn parse_remote_opt(s: &str) -> Result { // Set protocol to default protocol #[cfg(not(test))] // NOTE: don't use configuration during tests - let mut protocol: FileTransferProtocol = match environment::init_config_dir() { + let default_protocol: FileTransferProtocol = match environment::init_config_dir() { Ok(p) => match p { Some(p) => { // Create config client @@ -111,28 +135,60 @@ pub fn parse_remote_opt(remote: &str) -> Result { Err(_) => FileTransferProtocol::Sftp, }; #[cfg(test)] // NOTE: during test set protocol just to Sftp - let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; - // Match against regex - match REMOTE_OPT_REGEX.captures(remote) { + let default_protocol: FileTransferProtocol = FileTransferProtocol::Sftp; + // Get protocol + let (protocol, s): (FileTransferProtocol, String) = + parse_remote_opt_protocol(s, default_protocol)?; + // Match against regex for protocol type + match protocol { + FileTransferProtocol::AwsS3 => parse_s3_remote_opt(s.as_str()), + protocol => parse_generic_remote_opt(s.as_str(), protocol), + } +} + +/// ### parse_remote_opt_protocol +/// +/// Parse protocol from CLI option. In case of success, return the protocol to be used and the remaining arguments +fn parse_remote_opt_protocol( + s: &str, + default: FileTransferProtocol, +) -> Result<(FileTransferProtocol, String), String> { + match REMOTE_OPT_PROTOCOL_REGEX.captures(s) { + Some(groups) => { + // Parse protocol or use default + let protocol = groups.get(1).map(|x| { + FileTransferProtocol::from_str(x.as_str()) + .map_err(|_| format!("Unknown protocol \"{}\"", x.as_str())) + }); + let protocol = match protocol { + Some(Ok(protocol)) => protocol, + Some(Err(err)) => return Err(err), + None => default, + }; + // Return protocol and remaining arguments + Ok(( + protocol, + groups + .get(2) + .map(|x| x.as_str().to_string()) + .unwrap_or_default(), + )) + } + None => Err("Invalid args".to_string()), + } +} + +/// ### parse_generic_remote_opt +/// +/// Parse generic remote options +fn parse_generic_remote_opt( + s: &str, + protocol: FileTransferProtocol, +) -> Result { + match REMOTE_GENERIC_OPT_REGEX.captures(s) { Some(groups) => { - // Match protocol - let mut port: u16 = 22; - if let Some(group) = groups.get(1) { - // Set protocol from group - let (m_protocol, m_port) = match FileTransferProtocol::from_str(group.as_str()) { - Ok(proto) => match proto { - FileTransferProtocol::Ftp(_) => (proto, 21), - FileTransferProtocol::Scp => (proto, 22), - FileTransferProtocol::Sftp => (proto, 22), - }, - Err(_) => return Err(format!("Unknown protocol \"{}\"", group.as_str())), - }; - // NOTE: tuple destructuring assignment is not supported yet :( - protocol = m_protocol; - port = m_port; - } // Match user - let username: Option = match groups.get(2) { + let username: Option = match groups.get(1) { Some(group) => Some(group.as_str().to_string()), None => match protocol { // If group is empty, set to current user @@ -143,25 +199,62 @@ pub fn parse_remote_opt(remote: &str) -> Result { }, }; // Get address - let address: String = match groups.get(3) { + let address: String = match groups.get(2) { Some(group) => group.as_str().to_string(), None => return Err(String::from("Missing address")), }; // Get port - if let Some(group) = groups.get(4) { - port = match group.as_str().parse::() { + let port: u16 = match groups.get(3) { + Some(port) => match port.as_str().parse::() { + // Try to parse port Ok(p) => p, - Err(err) => return Err(format!("Bad port \"{}\": {}", group.as_str(), err)), - }; - } + Err(err) => return Err(format!("Bad port \"{}\": {}", port.as_str(), err)), + }, + None => match protocol { + // Set port based on protocol + FileTransferProtocol::Ftp(_) => 21, + FileTransferProtocol::Scp => 22, + FileTransferProtocol::Sftp => 22, + _ => 22, // Doesn't matter + }, + }; // Get workdir let entry_directory: Option = - groups.get(5).map(|group| PathBuf::from(group.as_str())); - Ok(FileTransferParams::new(address) - .port(port) - .protocol(protocol) - .username(username) - .entry_directory(entry_directory)) + groups.get(4).map(|group| PathBuf::from(group.as_str())); + let params: ProtocolParams = ProtocolParams::Generic( + GenericProtocolParams::default() + .address(address) + .port(port) + .username(username), + ); + Ok(FileTransferParams::new(protocol, params).entry_directory(entry_directory)) + } + None => Err(String::from("Bad remote host syntax!")), + } +} + +/// ### parse_s3_remote_opt +/// +/// Parse remote options for s3 protocol +fn parse_s3_remote_opt(s: &str) -> Result { + match REMOTE_S3_OPT_REGEX.captures(s) { + Some(groups) => { + let bucket: String = groups + .get(1) + .map(|x| x.as_str().to_string()) + .unwrap_or_default(); + let region: String = groups + .get(2) + .map(|x| x.as_str().to_string()) + .unwrap_or_default(); + let profile: Option = groups.get(3).map(|x| x.as_str().to_string()); + let entry_directory: Option = + groups.get(4).map(|group| PathBuf::from(group.as_str())); + Ok(FileTransferParams::new( + FileTransferProtocol::AwsS3, + ProtocolParams::AwsS3(AwsS3Params::new(bucket, region, profile)), + ) + .entry_directory(entry_directory)) } None => Err(String::from("Bad remote host syntax!")), } @@ -470,101 +563,127 @@ mod tests { let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 22); + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert!(result.username.is_some()); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 22); + assert!(params.username.is_some()); // User case let result: FileTransferParams = parse_remote_opt(&String::from("root@172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 22); + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert_eq!(result.username.unwrap(), String::from("root")); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 22); + assert_eq!( + params.username.as_deref().unwrap().to_string(), + String::from("root") + ); assert!(result.entry_directory.is_none()); // User + port let result: FileTransferParams = parse_remote_opt(&String::from("root@172.26.104.1:8022")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 8022); + let params = result.params.generic_params().unwrap(); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 8022); + assert_eq!( + params.username.as_deref().unwrap().to_string(), + String::from("root") + ); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert_eq!(result.username.unwrap(), String::from("root")); assert!(result.entry_directory.is_none()); // Port only let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1:4022")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 4022); + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert!(result.username.is_some()); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 4022); + assert!(params.username.is_some()); assert!(result.entry_directory.is_none()); // Protocol let result: FileTransferParams = parse_remote_opt(&String::from("ftp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 21); // Fallback to ftp default + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Ftp(false)); - assert!(result.username.is_none()); // Doesn't fall back + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 21); // Fallback to ftp default + assert!(params.username.is_none()); // Doesn't fall back assert!(result.entry_directory.is_none()); // Protocol let result: FileTransferParams = parse_remote_opt(&String::from("sftp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 22); // Fallback to sftp default + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert!(result.username.is_some()); // Doesn't fall back + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 22); // Fallback to sftp default + assert!(params.username.is_some()); // Doesn't fall back assert!(result.entry_directory.is_none()); let result: FileTransferParams = parse_remote_opt(&String::from("scp://172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 22); // Fallback to scp default + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Scp); - assert!(result.username.is_some()); // Doesn't fall back + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 22); // Fallback to scp default + assert!(params.username.is_some()); // Doesn't fall back assert!(result.entry_directory.is_none()); // Protocol + user let result: FileTransferParams = parse_remote_opt(&String::from("ftps://anon@172.26.104.1")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 21); // Fallback to ftp default + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Ftp(true)); - assert_eq!(result.username.unwrap(), String::from("anon")); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 21); // Fallback to ftp default + assert_eq!( + params.username.as_deref().unwrap().to_string(), + String::from("anon") + ); assert!(result.entry_directory.is_none()); // Path let result: FileTransferParams = parse_remote_opt(&String::from("root@172.26.104.1:8022:/var")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 8022); + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert_eq!(result.username.unwrap(), String::from("root")); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 8022); + assert_eq!( + params.username.as_deref().unwrap().to_string(), + String::from("root") + ); assert_eq!(result.entry_directory.unwrap(), PathBuf::from("/var")); // Port only let result: FileTransferParams = parse_remote_opt(&String::from("172.26.104.1:home")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 22); + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Sftp); - assert!(result.username.is_some()); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 22); + assert!(params.username.is_some()); assert_eq!(result.entry_directory.unwrap(), PathBuf::from("home")); // All together now let result: FileTransferParams = parse_remote_opt(&String::from("ftp://anon@172.26.104.1:8021:/tmp")) .ok() .unwrap(); - assert_eq!(result.address, String::from("172.26.104.1")); - assert_eq!(result.port, 8021); // Fallback to ftp default + let params = result.params.generic_params().unwrap(); assert_eq!(result.protocol, FileTransferProtocol::Ftp(false)); - assert_eq!(result.username.unwrap(), String::from("anon")); + assert_eq!(params.address, String::from("172.26.104.1")); + assert_eq!(params.port, 8021); // Fallback to ftp default + assert_eq!( + params.username.as_deref().unwrap().to_string(), + String::from("anon") + ); assert_eq!(result.entry_directory.unwrap(), PathBuf::from("/tmp")); // bad syntax // Bad protocol @@ -573,6 +692,56 @@ mod tests { assert!(parse_remote_opt(&String::from("scp://172.26.104.1:650000")).is_err()); } + #[test] + fn parse_aws_s3_opt() { + // Simple + let result: FileTransferParams = + parse_remote_opt(&String::from("s3://mybucket@eu-central-1")) + .ok() + .unwrap(); + let params = result.params.s3_params().unwrap(); + assert_eq!(result.protocol, FileTransferProtocol::AwsS3); + assert_eq!(result.entry_directory, None); + assert_eq!(params.bucket_name.as_str(), "mybucket"); + assert_eq!(params.region.as_str(), "eu-central-1"); + assert_eq!(params.profile, None); + // With profile + let result: FileTransferParams = + parse_remote_opt(&String::from("s3://mybucket@eu-central-1:default")) + .ok() + .unwrap(); + let params = result.params.s3_params().unwrap(); + assert_eq!(result.protocol, FileTransferProtocol::AwsS3); + assert_eq!(result.entry_directory, None); + assert_eq!(params.bucket_name.as_str(), "mybucket"); + assert_eq!(params.region.as_str(), "eu-central-1"); + assert_eq!(params.profile.as_deref(), Some("default")); + // With wrkdir only + let result: FileTransferParams = + parse_remote_opt(&String::from("s3://mybucket@eu-central-1:/foobar")) + .ok() + .unwrap(); + let params = result.params.s3_params().unwrap(); + assert_eq!(result.protocol, FileTransferProtocol::AwsS3); + assert_eq!(result.entry_directory, Some(PathBuf::from("/foobar"))); + assert_eq!(params.bucket_name.as_str(), "mybucket"); + assert_eq!(params.region.as_str(), "eu-central-1"); + assert_eq!(params.profile, None); + // With all arguments + let result: FileTransferParams = + parse_remote_opt(&String::from("s3://mybucket@eu-central-1:default:/foobar")) + .ok() + .unwrap(); + let params = result.params.s3_params().unwrap(); + assert_eq!(result.protocol, FileTransferProtocol::AwsS3); + assert_eq!(result.entry_directory, Some(PathBuf::from("/foobar"))); + assert_eq!(params.bucket_name.as_str(), "mybucket"); + assert_eq!(params.region.as_str(), "eu-central-1"); + assert_eq!(params.profile.as_deref(), Some("default")); + // -- bad args + assert!(parse_remote_opt(&String::from("s3://mybucket:default:/foobar")).is_err()); + } + #[test] fn test_utils_parse_lstime() { // Good cases diff --git a/src/utils/path.rs b/src/utils/path.rs index 1621009..6a2ec82 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -2,7 +2,7 @@ //! //! Path related utilities -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; /// ### absolutize /// @@ -24,6 +24,64 @@ pub fn absolutize(wrkdir: &Path, target: &Path) -> PathBuf { } } +/// ### diff_paths +/// +/// This function will get the difference from path `path` to `base`. Basically will remove `base` from `path` +/// +/// For example: +/// +/// ```rust +/// assert_eq!(diff_paths(&Path::new("/foo/bar"), &Path::new("/")).as_path(), Path::new("foo/bar")); +/// assert_eq!(diff_paths(&Path::new("/foo/bar"), &Path::new("/foo")).as_path(), Path::new("bar")); +/// ``` +/// +/// This function has been written by +/// and is licensed under the APACHE-2/MIT license +pub fn diff_paths(path: P, base: B) -> Option +where + P: AsRef, + B: AsRef, +{ + let path = path.as_ref(); + let base = base.as_ref(); + + if path.is_absolute() != base.is_absolute() { + if path.is_absolute() { + Some(PathBuf::from(path)) + } else { + None + } + } else { + let mut ita = path.components(); + let mut itb = base.components(); + let mut comps: Vec = vec![]; + loop { + match (ita.next(), itb.next()) { + (None, None) => break, + (Some(a), None) => { + comps.push(a); + comps.extend(ita.by_ref()); + break; + } + (None, _) => comps.push(Component::ParentDir), + (Some(a), Some(b)) if comps.is_empty() && a == b => (), + (Some(a), Some(b)) if b == Component::CurDir => comps.push(a), + (Some(_), Some(b)) if b == Component::ParentDir => return None, + (Some(a), Some(_)) => { + comps.push(Component::ParentDir); + for _ in itb { + comps.push(Component::ParentDir); + } + comps.push(a); + comps.extend(ita.by_ref()); + break; + } + } + } + Some(comps.iter().map(|c| c.as_os_str()).collect()) + } +} + #[cfg(test)] mod test { @@ -40,4 +98,26 @@ mod test { Path::new("/tmp/readme.txt") ); } + + #[test] + fn calc_diff_paths() { + assert_eq!( + diff_paths(&Path::new("/foo/bar"), &Path::new("/")) + .unwrap() + .as_path(), + Path::new("foo/bar") + ); + assert_eq!( + diff_paths(&Path::new("/foo/bar"), &Path::new("/foo")) + .unwrap() + .as_path(), + Path::new("bar") + ); + assert_eq!( + diff_paths(&Path::new("/foo/bar/chiedo.gif"), &Path::new("/")) + .unwrap() + .as_path(), + Path::new("foo/bar/chiedo.gif") + ); + } } diff --git a/src/utils/test_helpers.rs b/src/utils/test_helpers.rs index 8ec02f0..534a768 100644 --- a/src/utils/test_helpers.rs +++ b/src/utils/test_helpers.rs @@ -28,9 +28,9 @@ use crate::fs::{FsDirectory, FsEntry, FsFile, UnixPex}; // ext use std::fs::File; -#[cfg(feature = "with-containers")] +#[cfg(any(feature = "with-containers", feature = "with-s3-ci"))] use std::fs::OpenOptions; -#[cfg(feature = "with-containers")] +#[cfg(any(feature = "with-containers", feature = "with-s3-ci"))] use std::io::Read; use std::io::Write; use std::path::{Path, PathBuf}; @@ -97,7 +97,7 @@ pub fn make_dir_at(dir: &Path, dirname: &str) -> std::io::Result<()> { std::fs::create_dir(p.as_path()) } -#[cfg(feature = "with-containers")] +#[cfg(any(feature = "with-containers", feature = "with-s3-ci"))] pub fn write_file(file: &NamedTempFile, writable: &mut Box) { let mut fhnd = OpenOptions::new() .create(false) @@ -153,7 +153,8 @@ RorU9FCmS/654wAAABFyb290QDhjNTBmZDRjMzQ1YQECAw== /// ### make_fsentry /// /// Create a FsEntry at specified path -pub fn make_fsentry(path: PathBuf, is_dir: bool) -> FsEntry { +pub fn make_fsentry>(path: P, is_dir: bool) -> FsEntry { + let path: PathBuf = path.as_ref().to_path_buf(); match is_dir { true => FsEntry::Directory(FsDirectory { name: path.file_name().unwrap().to_string_lossy().to_string(),