From 974b6d6917d3d0f28c19260da2e00046f710d745 Mon Sep 17 00:00:00 2001 From: Christian Visintin Date: Sun, 30 Aug 2026 18:58:17 +0200 Subject: [PATCH] feat(gcs): add Google Cloud Storage support (#443) * feat(gcs): add Google Cloud Storage support Closes #436 * fix: honor configured protocol and finalization errors * ci: remove TruffleHog checks --- .githooks/pre-commit | 15 +- .github/workflows/release.yml | 2 +- CLAUDE.md | 6 +- Cargo.lock | 923 +++++++++++++++++- Cargo.toml | 5 +- README.md | 3 +- docs/en-US/book.toml | 2 +- docs/en-US/developer/developer.md | 6 +- docs/en-US/getting-started/connecting.md | 2 +- .../getting-started/connection-parameters.md | 31 + docs/en-US/index.md | 2 +- docs/en-US/theme/head.hbs | 4 +- docs/misc/README.deb.txt | 5 +- docs/zh-CN/README.md | 3 +- docs/zh-CN/book.toml | 2 +- docs/zh-CN/developer/developer.md | 4 +- docs/zh-CN/getting-started/connecting.md | 2 +- .../getting-started/connection-parameters.md | 27 + docs/zh-CN/index.md | 2 +- docs/zh-CN/theme/head.hbs | 4 +- just/code_check.just | 5 - src/cli.rs | 1 + src/config/bookmarks.rs | 74 +- src/config/bookmarks/gcs.rs | 65 ++ src/config/serialization.rs | 107 +- src/filetransfer.rs | 15 + src/filetransfer/params.rs | 15 + .../params/google_cloud_storage.rs | 77 ++ src/filetransfer/remotefs_builder.rs | 96 +- src/system/bookmarks_client.rs | 66 +- src/system/config_client.rs | 17 + src/ui/activities/auth.rs | 46 +- src/ui/activities/auth/bookmarks.rs | 26 +- src/ui/activities/auth/components.rs | 12 +- src/ui/activities/auth/components/form.rs | 15 +- src/ui/activities/auth/components/form/gcs.rs | 144 +++ .../auth/components/form/protocol.rs | 17 +- src/ui/activities/auth/misc.rs | 27 + src/ui/activities/auth/update.rs | 50 + src/ui/activities/auth/view.rs | 10 + src/ui/activities/auth/view/mounting.rs | 50 + src/ui/activities/auth/view/query.rs | 67 +- src/ui/activities/auth/view/visible.rs | 34 + src/ui/activities/filetransfer/misc/host.rs | 6 + .../filetransfer/session/transfer.rs | 29 +- src/ui/activities/setup.rs | 5 +- src/ui/activities/setup/components/config.rs | 10 +- src/ui/activities/setup/view/setup.rs | 5 +- src/utils/parser.rs | 34 + src/utils/parser/ports.rs | 1 + src/utils/parser/remote.rs | 21 +- 51 files changed, 2101 insertions(+), 96 deletions(-) create mode 100644 src/config/bookmarks/gcs.rs create mode 100644 src/filetransfer/params/google_cloud_storage.rs create mode 100644 src/ui/activities/auth/components/form/gcs.rs diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3ac6bd6..b29feb3 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,10 +2,9 @@ # # termscp pre-commit hook. # -# Runs three gates before a commit is recorded: -# 1. trufflehog -- scan the staged tree for verified/unknown secrets -# 2. dprint -- check formatting (Markdown, TOML, YAML, Rust) -# 3. cargo-deny -- advisories, licenses, bans, and sources +# Runs two gates before a commit is recorded: +# 1. dprint -- check formatting (Markdown, TOML, YAML, Rust) +# 2. cargo-deny -- advisories, licenses, bans, and sources # # Install with `just setup_githooks` (sets core.hooksPath to .githooks). # Bypass in an emergency with `git commit --no-verify`. @@ -20,8 +19,6 @@ fail() { command -v just >/dev/null 2>&1 || fail "just not found; install it to run the pre-commit checks" if ! git diff --cached --quiet --diff-filter=ACMR --; then - command -v trufflehog >/dev/null 2>&1 || fail "trufflehog not found; install it or commit with --no-verify" - # Check the exact tree that will be committed, not possibly different # working-tree contents. The trailing slash is required by checkout-index. index_tree="$(mktemp -d)" @@ -31,12 +28,6 @@ if ! git diff --cached --quiet --diff-filter=ACMR --; then trap cleanup EXIT git checkout-index --all --prefix="$index_tree/" - echo "pre-commit: scanning staged tree for secrets" - ( - cd "$index_tree" - just scan_secrets . --fail-on-scan-errors - ) - echo "pre-commit: checking staged-tree formatting" ( cd "$index_tree" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c37dfb1..5413849 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -260,7 +260,7 @@ jobs: BASE="https://github.com/veeso/termscp/releases/latest/download" cat > tap/Formula/termscp.rb <[:/working/directory] +``` + +CLI connections use ADC and the default endpoint. Use the authentication form +or a bookmark when you need a custom endpoint or a service-account JSON path. +The selected ADC identity or service account must have IAM permissions for the +storage operations you want to perform. + ## SMB Authentication-form fields: diff --git a/docs/en-US/index.md b/docs/en-US/index.md index a4e0d6f..e78a564 100644 --- a/docs/en-US/index.md +++ b/docs/en-US/index.md @@ -9,7 +9,7 @@ same time. termscp runs on Linux, macOS, FreeBSD, NetBSD, and Windows. ## Features -- Multiple transfer protocols: SFTP, SCP, FTP and FTPS, Kube, S3, SMB, +- Multiple transfer protocols: SFTP, SCP, FTP and FTPS, Kube, S3, GCS, SMB, and WebDAV. - Dual-pane explorer to browse and operate on both the remote and the local file system: create, remove, rename, search, view, and edit files. diff --git a/docs/en-US/theme/head.hbs b/docs/en-US/theme/head.hbs index 6d586ff..0bb0947 100644 --- a/docs/en-US/theme/head.hbs +++ b/docs/en-US/theme/head.hbs @@ -2,12 +2,12 @@ - + - + diff --git a/docs/misc/README.deb.txt b/docs/misc/README.deb.txt index 7effc5e..ce923cd 100644 --- a/docs/misc/README.deb.txt +++ b/docs/misc/README.deb.txt @@ -1,4 +1,4 @@ -Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/WebDAV. +Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV. 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. @@ -9,6 +9,9 @@ Features: - SCP - FTP and FTPS - S3 + - GCS + - SMB + - WebDAV - 🖥 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 diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 29ef5b8..3404e7f 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -35,7 +35,7 @@ ## 关于 termscp 🖥 -termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/SFTP/FTP/Kube/S3/WebDAV。 简而言之,它是一个带有 TUI 的终端工具,可以连接到远程服务器进行文件的检索和上传,并能够与本地文件系统进行交互。 它兼容 **Linux**、**MacOS**、**FreeBSD**、**NetBSD** 和 **Windows** 操作系统。 +termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/SFTP/FTP/Kube/S3/Google Cloud Storage (GCS)/WebDAV。 简而言之,它是一个带有 TUI 的终端工具,可以连接到远程服务器进行文件的检索和上传,并能够与本地文件系统进行交互。 它兼容 **Linux**、**MacOS**、**FreeBSD**、**NetBSD** 和 **Windows** 操作系统。 ![Explorer](assets/images/explorer.gif) @@ -49,6 +49,7 @@ termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/S - **FTP** 和 **FTPS** - **Kube** - **S3** + - **Google Cloud Storage (GCS)** - **SMB** - **WebDAV** - 🖥 使用便捷的 UI 在远程和本地文件系统上浏览和操作 diff --git a/docs/zh-CN/book.toml b/docs/zh-CN/book.toml index 8f9ced3..27571b4 100644 --- a/docs/zh-CN/book.toml +++ b/docs/zh-CN/book.toml @@ -1,6 +1,6 @@ [book] title = "termscp" -description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV" +description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV" authors = ["Christian Visintin"] language = "zh" src = "." diff --git a/docs/zh-CN/developer/developer.md b/docs/zh-CN/developer/developer.md index 0f29ccd..fbdff26 100644 --- a/docs/zh-CN/developer/developer.md +++ b/docs/zh-CN/developer/developer.md @@ -2,7 +2,7 @@ 欢迎阅读 termscp 的开发者手册。本章不包含 termscp 各模块的文档,相关文档可以在 Rust Docs 上找到:。本章描述 termscp 的工作原理,以及实现诸如文件传输和用户界面扩展等功能的指南。 -termscp 使用 Rust 编写(edition 2024,MSRV 1.89.0)。用户界面使用 [tuirealm](https://github.com/veeso/tui-realm) v3 构建,它运行在 [crossterm](https://github.com/crossterm-rs/crossterm) 之上。 +termscp 使用 Rust 编写(edition 2024,MSRV 1.98.0)。用户界面使用 [tuirealm](https://github.com/veeso/tui-realm) v3 构建,它运行在 [crossterm](https://github.com/crossterm-rs/crossterm) 之上。 ## termscp 的工作原理 @@ -20,7 +20,7 @@ termscp 基本上由 3 个核心模块组成: - **system**:提供与配置、ssh 密钥存储和书签交互的方式。 - **utils**:包含几乎整个项目都会使用的工具。 -termscp 支持以下协议:SFTP、SCP、FTP/FTPS、Kube、S3、SMB 和 WebDAV。 +termscp 支持以下协议:SFTP、SCP、FTP/FTPS、Kube、S3、GCS、SMB 和 WebDAV。 ## Activities diff --git a/docs/zh-CN/getting-started/connecting.md b/docs/zh-CN/getting-started/connecting.md index 9c2124e..012e5cb 100644 --- a/docs/zh-CN/getting-started/connecting.md +++ b/docs/zh-CN/getting-started/connecting.md @@ -46,7 +46,7 @@ termscp scp://omar@192.168.1.31:4022 termscp scp://omar@192.168.1.31:4022:/tmp ``` -有关各协议专属的地址语法(S3、Kube、WebDAV 和 SMB),请参阅[连接参数](connection-parameters.md)。 +有关各协议专属的地址语法(S3、GCS、Kube、WebDAV 和 SMB),请参阅[连接参数](connection-parameters.md)。 ## 密码的提供方式 diff --git a/docs/zh-CN/getting-started/connection-parameters.md b/docs/zh-CN/getting-started/connection-parameters.md index a48f84a..b2d451e 100644 --- a/docs/zh-CN/getting-started/connection-parameters.md +++ b/docs/zh-CN/getting-started/connection-parameters.md @@ -114,6 +114,33 @@ s3://buckethead@eu-central-1:default:/assets 你的凭据是安全的:termscp 不会直接操作这些值。它们由 `s3` crate 直接使用。 +## Google Cloud Storage + +termscp 通过 Google Cloud Storage JSON API 支持 Google Cloud Storage(GCS)存储桶。 + +认证表单字段: + +- 存储桶名称(必填) +- 端点(默认为 `https://storage.googleapis.com`) +- 可选的服务账号 JSON 路径 + +将服务账号 JSON 路径留空即可使用应用默认凭据(ADC)。当 termscp 在 Google +Cloud 基础设施上运行时,ADC 可以从 `GOOGLE_APPLICATION_CREDENTIALS`、本地 +gcloud ADC 凭据或 Google Cloud 元数据服务中获取凭据。 + +提供服务账号 JSON 路径后,termscp 会在连接时读取该文件。书签只保存该路径, +不会复制服务账号 JSON 或其中的私钥。 + +专用的 CLI 语法如下: + +```txt +gcs://[:/working/directory] +``` + +CLI 连接使用 ADC 和默认端点。如果需要自定义端点或服务账号 JSON 路径,请使用 +认证表单或书签。所选 ADC 身份或服务账号必须拥有你想执行的存储操作所需的 IAM +权限。 + ## SMB 认证表单字段: diff --git a/docs/zh-CN/index.md b/docs/zh-CN/index.md index bece388..f537815 100644 --- a/docs/zh-CN/index.md +++ b/docs/zh-CN/index.md @@ -6,7 +6,7 @@ termscp 是一款功能丰富、带有 TUI(终端用户界面)的终端文 ## 功能特性 -- 支持多种传输协议:SFTP、SCP、FTP 和 FTPS、Kube、S3、SMB 以及 WebDAV。 +- 支持多种传输协议:SFTP、SCP、FTP 和 FTPS、Kube、S3、GCS、SMB 以及 WebDAV。 - 双面板浏览器,可同时浏览并操作远程和本地文件系统:创建、删除、重命名、搜索、查看和编辑文件。 - 书签和最近连接记录,帮助你快速重新连接到常用的主机。 - 使用你喜爱的编辑器查看和编辑文件。 diff --git a/docs/zh-CN/theme/head.hbs b/docs/zh-CN/theme/head.hbs index 6d586ff..0bb0947 100644 --- a/docs/zh-CN/theme/head.hbs +++ b/docs/zh-CN/theme/head.hbs @@ -2,12 +2,12 @@ - + - + diff --git a/just/code_check.just b/just/code_check.just index 5dd4cdb..961f64e 100644 --- a/just/code_check.just +++ b/just/code_check.just @@ -25,11 +25,6 @@ doc args="": deny args="": cargo deny check {{ args }} -# Scan for secrets with trufflehog (defaults to the whole working tree) -[group('code_check')] -scan_secrets *args=".": - trufflehog filesystem {{ args }} --results=verified,unknown --fail --no-update - # Point git at the tracked .githooks directory (installs the pre-commit hook) [group('code_check')] setup_githooks: diff --git a/src/cli.rs b/src/cli.rs index 043251a..be57ccd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,7 @@ 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 + - `gcs://[:/working/directory]` for Google Cloud Storage (ADC) - `\\\\[:port]\\[\\path]` for SMB (on Windows) - `smb://[user@][:port][/path]` for SMB (on other systems) diff --git a/src/config/bookmarks.rs b/src/config/bookmarks.rs index 6906523..b42696d 100644 --- a/src/config/bookmarks.rs +++ b/src/config/bookmarks.rs @@ -3,6 +3,7 @@ //! `bookmarks` is the module which provides data types and de/serializer for bookmarks mod aws_s3; +mod gcs; mod kube; mod smb; @@ -14,11 +15,12 @@ use serde::de::Error as DeError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub use self::aws_s3::S3Params; +pub use self::gcs::GcsParams; pub use self::kube::KubeParams; pub use self::smb::SmbParams; use crate::filetransfer::params::{ - AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, - SmbParams as TransferSmbParams, WebDAVProtocolParams, + AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams, + ProtocolParams, SmbParams as TransferSmbParams, WebDAVProtocolParams, }; use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; @@ -55,6 +57,8 @@ pub struct Bookmark { pub kube: Option, /// S3 params; optional. When used other fields are empty for sure pub s3: Option, + /// Google Cloud Storage params; optional. When used other fields are empty for sure + pub gcs: Option, /// SMB params; optional. Extra params required for SMB protocol pub smb: Option, } @@ -78,6 +82,7 @@ impl From for Bookmark { local_path, kube: None, s3: None, + gcs: None, smb: None, }, ProtocolParams::AwsS3(params) => Self { @@ -90,6 +95,7 @@ impl From for Bookmark { local_path, kube: None, s3: Some(S3Params::from(params)), + gcs: None, smb: None, }, ProtocolParams::Kube(params) => Self { @@ -102,6 +108,7 @@ impl From for Bookmark { local_path, kube: Some(KubeParams::from(params)), s3: None, + gcs: None, smb: None, }, ProtocolParams::Smb(params) => Self { @@ -118,6 +125,7 @@ impl From for Bookmark { local_path, kube: None, s3: None, + gcs: None, }, ProtocolParams::WebDAV(parms) => Self { protocol, @@ -129,6 +137,20 @@ impl From for Bookmark { local_path, kube: None, s3: None, + gcs: None, + smb: None, + }, + ProtocolParams::GoogleCloudStorage(params) => Self { + protocol, + address: None, + port: None, + username: None, + password: None, + remote_path, + local_path, + kube: None, + s3: None, + gcs: Some(GcsParams::from(params)), smb: None, }, } @@ -144,6 +166,14 @@ impl From for FileTransferParams { let params = AwsS3Params::from(params); Self::new(FileTransferProtocol::AwsS3, ProtocolParams::AwsS3(params)) } + FileTransferProtocol::GoogleCloudStorage => { + let params = bookmark.gcs.unwrap_or_default(); + let params = GoogleCloudStorageParams::from(params); + Self::new( + FileTransferProtocol::GoogleCloudStorage, + ProtocolParams::GoogleCloudStorage(params), + ) + } FileTransferProtocol::Ftp(_) | FileTransferProtocol::Scp | FileTransferProtocol::Sftp => { @@ -224,6 +254,7 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT; #[test] fn test_bookmarks_default() { @@ -244,6 +275,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: None, }; let recent: Bookmark = Bookmark { @@ -256,6 +288,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: None, }; let mut bookmarks: HashMap = HashMap::with_capacity(1); @@ -348,6 +381,37 @@ mod tests { assert_eq!(s3.secret_access_key.as_deref().unwrap(), "pluto"); } + #[test] + fn should_convert_gcs_params_to_bookmark_and_back() { + let transfer = FileTransferParams::new( + FileTransferProtocol::GoogleCloudStorage, + ProtocolParams::GoogleCloudStorage( + GoogleCloudStorageParams::new("archive-bucket") + .service_account_key(Some("/keys/archive.json")), + ), + ) + .remote_path(Some("/backups")); + + let bookmark = Bookmark::from(transfer); + let gcs = bookmark.gcs.as_ref().unwrap(); + assert_eq!(gcs.bucket, "archive-bucket"); + assert_eq!(gcs.endpoint, DEFAULT_GCS_ENDPOINT); + assert_eq!( + gcs.service_account_key.as_deref(), + Some("/keys/archive.json") + ); + assert_eq!(bookmark.password, None); + + let restored = FileTransferParams::from(bookmark); + let params = restored.params.gcs_params().unwrap(); + assert_eq!(params.bucket_name, "archive-bucket"); + assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT); + assert_eq!( + params.service_account_key.as_deref(), + Some("/keys/archive.json") + ); + } + #[test] fn bookmark_from_kube_ftparams() { let params = ProtocolParams::Kube(KubeProtocolParams { @@ -388,6 +452,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: None, }; let params = FileTransferParams::from(bookmark); @@ -419,6 +484,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: None, }; let params = FileTransferParams::from(bookmark); @@ -457,6 +523,7 @@ mod tests { secret_access_key: Some(String::from("pluto")), new_path_style: Some(true), }), + gcs: None, smb: None, }; let params = FileTransferParams::from(bookmark); @@ -497,6 +564,7 @@ mod tests { client_key: Some(String::from("key")), }), s3: None, + gcs: None, smb: None, }; let params = FileTransferParams::from(bookmark); @@ -533,6 +601,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: Some(SmbParams { share: "test".to_string(), workgroup: Some("testone".to_string()), @@ -571,6 +640,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), s3: None, kube: None, + gcs: None, smb: Some(SmbParams { share: "test".to_string(), workgroup: None, diff --git a/src/config/bookmarks/gcs.rs b/src/config/bookmarks/gcs.rs new file mode 100644 index 0000000..cf63cf7 --- /dev/null +++ b/src/config/bookmarks/gcs.rs @@ -0,0 +1,65 @@ +//! ## Bookmark Google Cloud Storage Parameters +//! +//! Stores bookmark-specific Google Cloud Storage connection settings. + +use serde::{Deserialize, Serialize}; + +use crate::filetransfer::params::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams}; + +fn default_gcs_endpoint() -> String { + DEFAULT_GCS_ENDPOINT.to_string() +} + +/// Google Cloud Storage connection parameters stored in a bookmark. +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)] +pub struct GcsParams { + /// Bucket name to open. + pub bucket: String, + /// Google Cloud Storage endpoint URL. + #[serde(default = "default_gcs_endpoint")] + pub endpoint: String, + /// Optional path to a service-account JSON file. + pub service_account_key: Option, +} + +impl From for GcsParams { + fn from(params: GoogleCloudStorageParams) -> Self { + Self { + bucket: params.bucket_name, + endpoint: if params.endpoint.is_empty() { + default_gcs_endpoint() + } else { + params.endpoint + }, + service_account_key: params.service_account_key, + } + } +} + +impl From for GoogleCloudStorageParams { + fn from(params: GcsParams) -> Self { + GoogleCloudStorageParams::new(params.bucket) + .endpoint(if params.endpoint.is_empty() { + default_gcs_endpoint() + } else { + params.endpoint + }) + .service_account_key(params.service_account_key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_normalize_empty_endpoint() { + let params = GoogleCloudStorageParams::from(GcsParams { + bucket: String::from("archive-bucket"), + endpoint: String::new(), + service_account_key: None, + }); + + assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT); + } +} diff --git a/src/config/serialization.rs b/src/config/serialization.rs index 7bab606..b8e8e6f 100644 --- a/src/config/serialization.rs +++ b/src/config/serialization.rs @@ -366,7 +366,7 @@ mod tests { assert_eq!(host.username.as_deref().unwrap(), "root"); assert_eq!(host.password, None); // Verify bookmarks - assert_eq!(hosts.bookmarks.len(), 6); + assert_eq!(hosts.bookmarks.len(), 7); let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap(); assert_eq!(host.address.as_deref().unwrap(), "192.168.1.31"); assert_eq!(host.port.unwrap(), 22); @@ -404,6 +404,20 @@ mod tests { assert_eq!(s3.access_key.as_deref().unwrap(), "pippo"); assert_eq!(s3.secret_access_key.as_deref().unwrap(), "pluto"); assert_eq!(s3.new_path_style.unwrap(), true); + // Google Cloud Storage bucket + let host: &Bookmark = hosts.bookmarks.get("gcs-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::GoogleCloudStorage); + let gcs = host.gcs.as_ref().unwrap(); + assert_eq!(gcs.bucket, "archive-bucket"); + assert_eq!(gcs.endpoint, "https://storage.googleapis.com"); + assert_eq!( + gcs.service_account_key.as_deref(), + Some("/keys/archive.json") + ); // Kube pod let host: &Bookmark = hosts.bookmarks.get("pod").unwrap(); assert_eq!(host.address, None); @@ -461,6 +475,45 @@ mod tests { ); } + #[test] + fn should_deserialize_gcs_bookmark_without_service_account_key() { + let toml_file = create_gcs_adc_toml_bookmark(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().rewind().unwrap(); + + let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap(); + let host = hosts.bookmarks.get("gcs-adc").unwrap(); + + assert_eq!(host.protocol, FileTransferProtocol::GoogleCloudStorage); + let gcs = host.gcs.as_ref().unwrap(); + assert_eq!(gcs.bucket, "adc-bucket"); + assert_eq!(gcs.endpoint, "https://storage.googleapis.com"); + assert_eq!(gcs.service_account_key, None); + } + + #[test] + fn should_serialize_gcs_bookmark_fields() { + let toml_file = create_good_toml_bookmarks(); + toml_file.as_file().sync_all().unwrap(); + toml_file.as_file().rewind().unwrap(); + let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap(); + + let output_file = tempfile::NamedTempFile::new().unwrap(); + let output_path = output_file.path().to_path_buf(); + serialize( + &hosts, + Box::new(std::fs::File::create(&output_path).unwrap()), + ) + .unwrap(); + let output = std::fs::read_to_string(output_path).unwrap(); + + assert!(output.contains("protocol = \"GCS\"")); + assert!(output.contains("bucket = \"archive-bucket\"")); + assert!(output.contains("endpoint = \"https://storage.googleapis.com\"")); + assert!(output.contains("service_account_key = \"/keys/archive.json\"")); + assert!(output.contains("directory = \"/backups\"")); + } + #[test] fn test_should_fail_deserialize_bookmark_with_invalid_protocol() { let toml_file: tempfile::NamedTempFile = create_invalid_protocol_toml_bookmarks(); @@ -486,6 +539,7 @@ mod tests { local_path: None, kube: None, s3: None, + gcs: None, smb: None, }, ); @@ -501,6 +555,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), kube: None, s3: None, + gcs: None, smb: None, }, ); @@ -524,6 +579,27 @@ mod tests { new_path_style: None, }), kube: None, + gcs: None, + smb: None, + }, + ); + bookmarks.insert( + String::from("gcs-bucket"), + Bookmark { + address: None, + port: None, + protocol: FileTransferProtocol::GoogleCloudStorage, + username: None, + password: None, + remote_path: Some(PathBuf::from("/backups")), + local_path: None, + kube: None, + s3: None, + gcs: Some(crate::config::bookmarks::GcsParams { + bucket: "archive-bucket".to_string(), + endpoint: "https://storage.googleapis.com".to_string(), + service_account_key: Some("/keys/archive.json".to_string()), + }), smb: None, }, ); @@ -539,6 +615,7 @@ mod tests { remote_path: None, local_path: None, s3: None, + gcs: None, smb: None, kube: Some(KubeParams { namespace: Some("my-namespace".to_string()), @@ -566,6 +643,7 @@ mod tests { local_path: None, s3: None, kube: None, + gcs: None, smb: smb_params, }, ); @@ -582,6 +660,7 @@ mod tests { local_path: Some(PathBuf::from("/usr")), s3: None, kube: None, + gcs: None, smb: None, }, ); @@ -656,6 +735,15 @@ mod tests { secret_access_key = "pluto" new_path_style = true + [bookmarks.gcs-bucket] + protocol = "GCS" + directory = "/backups" + + [bookmarks.gcs-bucket.gcs] + bucket = "archive-bucket" + endpoint = "https://storage.googleapis.com" + service_account_key = "/keys/archive.json" + [bookmarks.pod] protocol = "KUBE" [bookmarks.pod.kube] @@ -684,6 +772,23 @@ mod tests { tmpfile } + fn create_gcs_adc_toml_bookmark() -> tempfile::NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); + let file_content: &str = r#" + [bookmarks] + + [bookmarks.gcs-adc] + protocol = "GCS" + + [bookmarks.gcs-adc.gcs] + bucket = "adc-bucket" + + [recents] + "#; + tmpfile.write_all(file_content.as_bytes()).unwrap(); + tmpfile + } + fn create_v14_pod_bookmark() -> tempfile::NamedTempFile { let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap(); let file_content: &str = r#" diff --git a/src/filetransfer.rs b/src/filetransfer.rs index 97a6583..c1208bd 100644 --- a/src/filetransfer.rs +++ b/src/filetransfer.rs @@ -17,6 +17,7 @@ pub use remotefs_builder::RemoteFsBuilder; pub enum FileTransferProtocol { AwsS3, Ftp(bool), // Bool is for secure (true => ftps) + GoogleCloudStorage, Kube, Scp, Sftp, @@ -37,6 +38,7 @@ impl std::fmt::Display for FileTransferProtocol { true => "FTPS", false => "FTP", }, + FileTransferProtocol::GoogleCloudStorage => "GCS", FileTransferProtocol::Kube => "KUBE", FileTransferProtocol::Scp => "SCP", FileTransferProtocol::Sftp => "SFTP", @@ -53,6 +55,7 @@ impl std::str::FromStr for FileTransferProtocol { match s.to_ascii_uppercase().as_str() { "FTP" => Ok(FileTransferProtocol::Ftp(false)), "FTPS" => Ok(FileTransferProtocol::Ftp(true)), + "GCS" => Ok(FileTransferProtocol::GoogleCloudStorage), "KUBE" => Ok(FileTransferProtocol::Kube), "S3" => Ok(FileTransferProtocol::AwsS3), "SCP" => Ok(FileTransferProtocol::Scp), @@ -143,6 +146,14 @@ mod tests { FileTransferProtocol::from_str("s3").ok().unwrap(), FileTransferProtocol::AwsS3 ); + assert_eq!( + FileTransferProtocol::from_str("GCS").ok().unwrap(), + FileTransferProtocol::GoogleCloudStorage + ); + assert_eq!( + FileTransferProtocol::from_str("gcs").ok().unwrap(), + FileTransferProtocol::GoogleCloudStorage + ); // Error assert!(FileTransferProtocol::from_str("dummy").is_err()); // To String @@ -161,6 +172,10 @@ 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")); + assert_eq!( + FileTransferProtocol::GoogleCloudStorage.to_string(), + String::from("GCS") + ); assert_eq!(FileTransferProtocol::Smb.to_string(), String::from("SMB")); assert_eq!( FileTransferProtocol::WebDAV.to_string(), diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs index a397b58..88ec871 100644 --- a/src/filetransfer/params.rs +++ b/src/filetransfer/params.rs @@ -3,6 +3,7 @@ //! file transfer parameters mod aws_s3; +mod google_cloud_storage; mod kube; mod smb; mod webdav; @@ -10,6 +11,7 @@ mod webdav; use std::path::{Path, PathBuf}; pub use self::aws_s3::AwsS3Params; +pub use self::google_cloud_storage::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams}; pub use self::kube::KubeProtocolParams; pub use self::smb::SmbParams; pub use self::webdav::WebDAVProtocolParams; @@ -66,6 +68,7 @@ impl FileTransferParams { pub enum ProtocolParams { Generic(GenericProtocolParams), AwsS3(AwsS3Params), + GoogleCloudStorage(GoogleCloudStorageParams), Kube(KubeProtocolParams), Smb(SmbParams), WebDAV(WebDAVProtocolParams), @@ -76,6 +79,7 @@ impl ProtocolParams { match self { ProtocolParams::AwsS3(params) => params.password_missing(), ProtocolParams::Generic(params) => params.password_missing(), + ProtocolParams::GoogleCloudStorage(params) => params.password_missing(), ProtocolParams::Kube(params) => params.password_missing(), ProtocolParams::Smb(params) => params.password_missing(), ProtocolParams::WebDAV(params) => params.password_missing(), @@ -87,6 +91,7 @@ impl ProtocolParams { match self { ProtocolParams::AwsS3(params) => params.set_default_secret(secret), ProtocolParams::Generic(params) => params.set_default_secret(secret), + ProtocolParams::GoogleCloudStorage(params) => params.set_default_secret(secret), ProtocolParams::Kube(params) => params.set_default_secret(secret), ProtocolParams::Smb(params) => params.set_default_secret(secret), ProtocolParams::WebDAV(params) => params.set_default_secret(secret), @@ -97,6 +102,7 @@ impl ProtocolParams { match self { ProtocolParams::AwsS3(params) => params.bucket_name.clone(), ProtocolParams::Generic(params) => params.address.clone(), + ProtocolParams::GoogleCloudStorage(params) => params.bucket_name.clone(), ProtocolParams::Kube(params) => params .namespace .as_ref() @@ -193,6 +199,15 @@ impl ProtocolParams { } } + #[cfg(test)] + /// Retrieve Google Cloud Storage parameters if any. + pub fn gcs_params(&self) -> Option<&GoogleCloudStorageParams> { + match self { + ProtocolParams::GoogleCloudStorage(params) => Some(params), + _ => None, + } + } + #[cfg(test)] /// Retrieve Kube params parameters if any pub fn kube_params(&self) -> Option<&KubeProtocolParams> { diff --git a/src/filetransfer/params/google_cloud_storage.rs b/src/filetransfer/params/google_cloud_storage.rs new file mode 100644 index 0000000..8b4e146 --- /dev/null +++ b/src/filetransfer/params/google_cloud_storage.rs @@ -0,0 +1,77 @@ +//! ## Google Cloud Storage Parameters +//! +//! Defines the runtime connection parameters used to build Google Cloud +//! Storage clients. + +/// Google Cloud Storage's default JSON API endpoint. +pub const DEFAULT_GCS_ENDPOINT: &str = "https://storage.googleapis.com"; + +/// Connection parameters for Google Cloud Storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoogleCloudStorageParams { + /// Target bucket name. + pub bucket_name: String, + /// Google Cloud Storage endpoint URL. + pub endpoint: String, + /// Optional path to a service-account JSON file. + pub service_account_key: Option, +} + +impl GoogleCloudStorageParams { + /// Creates Google Cloud Storage parameters using the default endpoint. + pub fn new>(bucket_name: S) -> Self { + Self { + bucket_name: bucket_name.into(), + endpoint: DEFAULT_GCS_ENDPOINT.to_string(), + service_account_key: None, + } + } + + /// Sets the Google Cloud Storage endpoint. + pub fn endpoint>(mut self, endpoint: S) -> Self { + self.endpoint = endpoint.into(); + self + } + + /// Sets the optional service-account JSON file path. + pub fn service_account_key>(mut self, path: Option) -> Self { + self.service_account_key = path.map(Into::into); + self + } + + /// Reports whether the protocol's default secret is missing. + pub fn password_missing(&self) -> bool { + false + } + + /// Ignores generic password secrets because GCS uses ADC or a credential file. + pub fn set_default_secret(&mut self, _secret: String) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_use_google_storage_default_endpoint() { + let params = GoogleCloudStorageParams::new("my-bucket"); + + assert_eq!(params.bucket_name, "my-bucket"); + assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT); + assert_eq!(params.service_account_key, None); + assert!(!params.password_missing()); + } + + #[test] + fn should_override_endpoint_and_credentials_path() { + let params = GoogleCloudStorageParams::new("my-bucket") + .endpoint("http://127.0.0.1:4443") + .service_account_key(Some("credentials.json")); + + assert_eq!(params.endpoint, "http://127.0.0.1:4443"); + assert_eq!( + params.service_account_key.as_deref(), + Some("credentials.json") + ); + } +} diff --git a/src/filetransfer/remotefs_builder.rs b/src/filetransfer/remotefs_builder.rs index df3e0f7..23f1db1 100644 --- a/src/filetransfer/remotefs_builder.rs +++ b/src/filetransfer/remotefs_builder.rs @@ -8,6 +8,8 @@ use std::sync::Arc; use remotefs::RemoteFs; use remotefs_aws_s3::AwsS3Fs; use remotefs_ftp::FtpFs; +use remotefs_gcs::credentials::service_account; +use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs}; use remotefs_kube::KubeMultiPodFs as KubeFs; #[cfg(smb_unix)] use remotefs_smb::SmbOptions; @@ -20,9 +22,9 @@ use remotefs_ssh::{ use remotefs_webdav::WebDAVFs; #[cfg(not(smb))] -use super::params::{AwsS3Params, GenericProtocolParams}; +use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams}; #[cfg(smb)] -use super::params::{AwsS3Params, GenericProtocolParams, SmbParams}; +use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, SmbParams}; use super::params::{KubeProtocolParams, WebDAVProtocolParams}; use super::{FileTransferProtocol, ProtocolParams}; use crate::system::config_client::ConfigClient; @@ -48,6 +50,10 @@ impl RemoteFsBuilder { (FileTransferProtocol::Ftp(secure), ProtocolParams::Generic(params)) => { Ok(Box::new(Self::ftp_client(params, secure))) } + ( + FileTransferProtocol::GoogleCloudStorage, + ProtocolParams::GoogleCloudStorage(params), + ) => Ok(Box::new(Self::gcs_client(params)?)), (FileTransferProtocol::Kube, ProtocolParams::Kube(params)) => { Ok(Box::new(Self::kube_client(params)?)) } @@ -108,6 +114,36 @@ impl RemoteFsBuilder { Ok(client) } + /// Build a Google Cloud Storage client from parameters. + fn gcs_client(params: GoogleCloudStorageParams) -> Result { + let runtime = Self::tokio_runtime()?; + let mut client = match params.service_account_key { + None => GoogleCloudStorageFs::new(params.bucket_name, &runtime), + Some(path) => { + let raw = std::fs::read_to_string(&path).map_err(|error| { + format!("Unable to read GCS service-account file '{path}': {error}") + })?; + let key = serde_json::from_str(&raw).map_err(|error| { + format!("Invalid GCS service-account JSON in '{path}': {error}") + })?; + let credentials = { + let _guard = runtime.enter(); + service_account::Builder::new(key).build() + } + .map_err(|error| { + format!("Invalid GCS service-account credentials in '{path}': {error}") + })?; + GoogleCloudStorageFs::with_credentials( + params.bucket_name, + GoogleCloudStorageCredentials::custom(credentials), + &runtime, + ) + } + }; + client = client.endpoint(params.endpoint); + Ok(client) + } + /// Build ftp client from parameters fn ftp_client(params: GenericProtocolParams, secure: bool) -> FtpFs { let mut client = FtpFs::new(params.address, params.port).passive_mode(); @@ -298,6 +334,62 @@ mod test { ); } + #[test] + fn should_build_gcs_fs_with_application_default_credentials() { + let params = ProtocolParams::GoogleCloudStorage(GoogleCloudStorageParams::new("my-bucket")); + let config_client = get_config_client(); + + assert!( + RemoteFsBuilder::build( + FileTransferProtocol::GoogleCloudStorage, + params, + &config_client, + ) + .is_ok() + ); + } + + #[test] + fn should_reject_missing_gcs_service_account_file() { + let directory = tempfile::TempDir::new().unwrap(); + let missing = directory.path().join("missing.json"); + let params = GoogleCloudStorageParams::new("my-bucket") + .service_account_key(Some(missing.to_string_lossy().into_owned())); + + assert!(RemoteFsBuilder::gcs_client(params).is_err()); + } + + #[test] + fn should_reject_malformed_gcs_service_account_json() { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "not-json").unwrap(); + let params = GoogleCloudStorageParams::new("my-bucket") + .service_account_key(Some(file.path().to_string_lossy().into_owned())); + + assert!(RemoteFsBuilder::gcs_client(params).is_err()); + } + + #[test] + fn should_build_gcs_fs_with_service_account_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#"{ + "type": "service_account", + "client_email": "termscp@example.iam.gserviceaccount.com", + "private_key_id": "test-key", + "private_key": "-----BEGIN PRIVATE KEY-----\ninvalid-test-key\n-----END PRIVATE KEY-----\n", + "project_id": "termscp-test", + "universe_domain": "googleapis.com" + }"#, + ) + .unwrap(); + let params = GoogleCloudStorageParams::new("my-bucket") + .service_account_key(Some(file.path().to_string_lossy().into_owned())); + + assert!(RemoteFsBuilder::gcs_client(params).is_ok()); + } + #[test] fn should_build_ftp_fs() { let params = ProtocolParams::Generic( diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index ae1d10f..397ad8e 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -379,7 +379,9 @@ mod tests { use tempfile::TempDir; use super::*; - use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams}; + use crate::filetransfer::params::{ + AwsS3Params, DEFAULT_GCS_ENDPOINT, GenericProtocolParams, GoogleCloudStorageParams, + }; use crate::filetransfer::{FileTransferProtocol, ProtocolParams}; #[test] @@ -526,6 +528,58 @@ mod tests { assert_eq!(params.secret_access_key, None); } + #[test] + fn should_preserve_gcs_service_account_path_for_saved_bookmarks() { + for save_password in [true, false] { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client = + BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap(); + + client + .add_bookmark( + "gcs-bucket", + make_gcs_ftparams(Some("/keys/archive.json")), + save_password, + ) + .unwrap(); + + let bookmark = client.get_bookmark("gcs-bucket").unwrap(); + let params = bookmark.params.gcs_params().unwrap(); + assert_eq!(params.bucket_name, "archive-bucket"); + assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT); + assert_eq!( + params.service_account_key.as_deref(), + Some("/keys/archive.json") + ); + assert_eq!(bookmark.password_missing(), false); + } + } + + #[test] + fn should_make_gcs_recent_without_password() { + let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client = + BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap(); + + client + .add_recent(make_gcs_ftparams(Some("/keys/archive.json")).remote_path(Some("/backups"))) + .unwrap(); + + let recent_key = client.iter_recents().next().unwrap().clone(); + let recent = client.get_recent(&recent_key).unwrap(); + let params = recent.params.gcs_params().unwrap(); + assert_eq!(params.bucket_name, "archive-bucket"); + assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT); + assert_eq!( + params.service_account_key.as_deref(), + Some("/keys/archive.json") + ); + assert_eq!(recent.password_missing(), false); + assert_eq!(recent.remote_path.as_deref(), Some(Path::new("/backups"))); + } + #[test] fn test_system_bookmarks_manipulate_bookmarks() { @@ -932,6 +986,16 @@ mod tests { ) } + fn make_gcs_ftparams(service_account_key: Option<&str>) -> FileTransferParams { + FileTransferParams::new( + FileTransferProtocol::GoogleCloudStorage, + ProtocolParams::GoogleCloudStorage( + GoogleCloudStorageParams::new("archive-bucket") + .service_account_key(service_account_key), + ), + ) + } + fn ftparams_to_tup( params: FileTransferParams, ) -> (String, u16, FileTransferProtocol, String, Option) { diff --git a/src/system/config_client.rs b/src/system/config_client.rs index dab6ff9..3cfe600 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -532,6 +532,23 @@ mod tests { ); } + #[test] + fn should_round_trip_gcs_as_default_protocol() { + let tmp_dir: TempDir = TempDir::new().ok().unwrap(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + + client.set_default_protocol(FileTransferProtocol::GoogleCloudStorage); + + assert_eq!( + client.get_default_protocol(), + FileTransferProtocol::GoogleCloudStorage + ); + assert_eq!(client.config.user_interface.default_protocol, "GCS"); + } + #[test] fn test_system_config_show_hidden_files() { let tmp_dir: TempDir = TempDir::new().ok().unwrap(); diff --git a/src/ui/activities/auth.rs b/src/ui/activities/auth.rs index 5319abe..e304193 100644 --- a/src/ui/activities/auth.rs +++ b/src/ui/activities/auth.rs @@ -31,9 +31,10 @@ const HOST_BRIDGE_RADIO_PROTOCOL_SCP: usize = 2; const HOST_BRIDGE_RADIO_PROTOCOL_FTP: usize = 3; const HOST_BRIDGE_RADIO_PROTOCOL_FTPS: usize = 4; const HOST_BRIDGE_RADIO_PROTOCOL_S3: usize = 5; -const HOST_BRIDGE_RADIO_PROTOCOL_KUBE: usize = 6; -const HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV: usize = 7; -const HOST_BRIDGE_RADIO_PROTOCOL_SMB: usize = 8; // Keep as last +const HOST_BRIDGE_RADIO_PROTOCOL_GCS: usize = 6; +const HOST_BRIDGE_RADIO_PROTOCOL_KUBE: usize = 7; +const HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV: usize = 8; +const HOST_BRIDGE_RADIO_PROTOCOL_SMB: usize = 9; // Keep as last // remote protocol radio const REMOTE_RADIO_PROTOCOL_SFTP: usize = 0; @@ -41,9 +42,10 @@ const REMOTE_RADIO_PROTOCOL_SCP: usize = 1; const REMOTE_RADIO_PROTOCOL_FTP: usize = 2; const REMOTE_RADIO_PROTOCOL_FTPS: usize = 3; const REMOTE_RADIO_PROTOCOL_S3: usize = 4; -const REMOTE_RADIO_PROTOCOL_KUBE: usize = 5; -const REMOTE_RADIO_PROTOCOL_WEBDAV: usize = 6; -const REMOTE_RADIO_PROTOCOL_SMB: usize = 7; // Keep as last +const REMOTE_RADIO_PROTOCOL_GCS: usize = 5; +const REMOTE_RADIO_PROTOCOL_KUBE: usize = 6; +const REMOTE_RADIO_PROTOCOL_WEBDAV: usize = 7; +const REMOTE_RADIO_PROTOCOL_SMB: usize = 8; // Keep as last // -- components #[derive(Debug, Eq, PartialEq, Clone, Hash)] @@ -74,6 +76,9 @@ pub enum Id { #[derive(Debug, Eq, PartialEq, Clone, Hash)] pub enum AuthFormId { Address, + GcsBucket, + GcsEndpoint, + GcsServiceAccountKey, KubeNamespace, KubeClusterUrl, KubeUsername, @@ -153,6 +158,12 @@ pub enum UiAuthFormMsg { AddressBlurDown, AddressBlurUp, ChangeFormTab, + GcsBucketBlurDown, + GcsBucketBlurUp, + GcsEndpointBlurDown, + GcsEndpointBlurUp, + GcsServiceAccountKeyBlurDown, + GcsServiceAccountKeyBlurUp, KubeNamespaceBlurDown, KubeNamespaceBlurUp, KubeClusterUrlBlurDown, @@ -209,6 +220,7 @@ pub enum UiAuthFormMsg { enum InputMask { Generic, AwsS3, + Gcs, Kube, Localhost, Smb, @@ -301,6 +313,10 @@ impl AuthActivity { Self::file_transfer_protocol_input_mask(self.remote_protocol) } + fn set_remote_protocol(&mut self, protocol: FileTransferProtocol) { + self.remote_protocol = protocol; + } + /// Get current input mask to show fn host_bridge_input_mask(&self) -> InputMask { match self.host_bridge_protocol { @@ -315,6 +331,7 @@ impl AuthActivity { fn file_transfer_protocol_input_mask(protocol: FileTransferProtocol) -> InputMask { match protocol { FileTransferProtocol::AwsS3 => InputMask::AwsS3, + FileTransferProtocol::GoogleCloudStorage => InputMask::Gcs, FileTransferProtocol::Ftp(_) | FileTransferProtocol::Scp | FileTransferProtocol::Sftp => InputMask::Generic, @@ -411,3 +428,20 @@ impl Activity for AuthActivity { self.context.take() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_set_configured_remote_protocol() { + let mut activity = AuthActivity::new(Duration::ZERO); + + activity.set_remote_protocol(FileTransferProtocol::GoogleCloudStorage); + + assert_eq!( + activity.remote_protocol, + FileTransferProtocol::GoogleCloudStorage + ); + } +} diff --git a/src/ui/activities/auth/bookmarks.rs b/src/ui/activities/auth/bookmarks.rs index 110cbac..ba1d634 100644 --- a/src/ui/activities/auth/bookmarks.rs +++ b/src/ui/activities/auth/bookmarks.rs @@ -6,8 +6,8 @@ use super::{AuthActivity, FileTransferParams, FormTab, HostBridgeProtocol}; use crate::filetransfer::HostBridgeParams; use crate::filetransfer::params::{ - AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, SmbParams, - WebDAVProtocolParams, + AwsS3Params, DEFAULT_GCS_ENDPOINT, GenericProtocolParams, GoogleCloudStorageParams, + KubeProtocolParams, ProtocolParams, SmbParams, WebDAVProtocolParams, }; impl AuthActivity { @@ -201,6 +201,9 @@ impl AuthActivity { ProtocolParams::AwsS3(params) => { self.load_bookmark_s3_into_gui(FormTab::HostBridge, params) } + ProtocolParams::GoogleCloudStorage(params) => { + self.load_bookmark_gcs_into_gui(FormTab::HostBridge, params) + } ProtocolParams::Kube(params) => { self.load_bookmark_kube_into_gui(FormTab::HostBridge, params) } @@ -240,6 +243,9 @@ impl AuthActivity { ProtocolParams::AwsS3(params) => { self.load_bookmark_s3_into_gui(FormTab::Remote, params) } + ProtocolParams::GoogleCloudStorage(params) => { + self.load_bookmark_gcs_into_gui(FormTab::Remote, params) + } ProtocolParams::Kube(params) => { self.load_bookmark_kube_into_gui(FormTab::Remote, params) } @@ -276,6 +282,22 @@ impl AuthActivity { self.mount_s3_new_path_style(form_tab, params.new_path_style); } + fn load_bookmark_gcs_into_gui(&mut self, form_tab: FormTab, params: GoogleCloudStorageParams) { + self.mount_gcs_bucket(form_tab, ¶ms.bucket_name); + self.mount_gcs_endpoint( + form_tab, + if params.endpoint.is_empty() { + DEFAULT_GCS_ENDPOINT + } else { + ¶ms.endpoint + }, + ); + self.mount_gcs_service_account_key( + form_tab, + params.service_account_key.as_deref().unwrap_or(""), + ); + } + fn load_bookmark_kube_into_gui(&mut self, form_tab: FormTab, params: KubeProtocolParams) { self.mount_kube_cluster_url(form_tab, params.cluster_url.as_deref().unwrap_or("")); self.mount_kube_namespace(form_tab, params.namespace.as_deref().unwrap_or("")); diff --git a/src/ui/activities/auth/components.rs b/src/ui/activities/auth/components.rs index 1b82da9..bd9ced4 100644 --- a/src/ui/activities/auth/components.rs +++ b/src/ui/activities/auth/components.rs @@ -16,12 +16,12 @@ pub use bookmarks::{ #[cfg(posix)] pub use form::InputSmbWorkgroup; pub use form::{ - HostBridgeProtocolRadio, InputAddress, InputKubeClientCert, InputKubeClientKey, - InputKubeClusterUrl, InputKubeNamespace, InputKubeUsername, InputLocalDirectory, InputPassword, - InputPort, InputRemoteDirectory, InputS3AccessKey, InputS3Bucket, InputS3Endpoint, - InputS3Profile, InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, - InputS3SessionToken, InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, - RemoteProtocolRadio, + HostBridgeProtocolRadio, InputAddress, InputGcsBucket, InputGcsEndpoint, + InputGcsServiceAccountKey, InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl, + InputKubeNamespace, InputKubeUsername, InputLocalDirectory, InputPassword, InputPort, + InputRemoteDirectory, InputS3AccessKey, InputS3Bucket, InputS3Endpoint, InputS3Profile, + InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, InputS3SessionToken, + InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, RemoteProtocolRadio, }; pub use popup::{ ErrorPopup, InfoPopup, InstallUpdatePopup, Keybindings, QuitPopup, ReleaseNotes, WaitPopup, diff --git a/src/ui/activities/auth/components/form.rs b/src/ui/activities/auth/components/form.rs index 5979b51..f84b31d 100644 --- a/src/ui/activities/auth/components/form.rs +++ b/src/ui/activities/auth/components/form.rs @@ -13,14 +13,18 @@ use tuirealm::props::{ use super::{FileTransferProtocol, FormMsg, Msg, UiMsg}; use crate::ui::activities::auth::{ FormTab, HOST_BRIDGE_RADIO_PROTOCOL_FTP, HOST_BRIDGE_RADIO_PROTOCOL_FTPS, - HOST_BRIDGE_RADIO_PROTOCOL_KUBE, HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST, - HOST_BRIDGE_RADIO_PROTOCOL_S3, HOST_BRIDGE_RADIO_PROTOCOL_SCP, HOST_BRIDGE_RADIO_PROTOCOL_SFTP, + HOST_BRIDGE_RADIO_PROTOCOL_GCS, HOST_BRIDGE_RADIO_PROTOCOL_KUBE, + HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST, HOST_BRIDGE_RADIO_PROTOCOL_S3, + HOST_BRIDGE_RADIO_PROTOCOL_SCP, HOST_BRIDGE_RADIO_PROTOCOL_SFTP, HOST_BRIDGE_RADIO_PROTOCOL_SMB, HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV, HostBridgeProtocol, - REMOTE_RADIO_PROTOCOL_FTP, REMOTE_RADIO_PROTOCOL_FTPS, REMOTE_RADIO_PROTOCOL_KUBE, - REMOTE_RADIO_PROTOCOL_S3, REMOTE_RADIO_PROTOCOL_SCP, REMOTE_RADIO_PROTOCOL_SFTP, - REMOTE_RADIO_PROTOCOL_SMB, REMOTE_RADIO_PROTOCOL_WEBDAV, UiAuthFormMsg, + REMOTE_RADIO_PROTOCOL_FTP, REMOTE_RADIO_PROTOCOL_FTPS, REMOTE_RADIO_PROTOCOL_GCS, + REMOTE_RADIO_PROTOCOL_KUBE, REMOTE_RADIO_PROTOCOL_S3, REMOTE_RADIO_PROTOCOL_SCP, + REMOTE_RADIO_PROTOCOL_SFTP, REMOTE_RADIO_PROTOCOL_SMB, REMOTE_RADIO_PROTOCOL_WEBDAV, + UiAuthFormMsg, }; +#[path = "form/gcs.rs"] +mod gcs; #[path = "form/generic.rs"] mod generic; #[path = "form/kube.rs"] @@ -36,6 +40,7 @@ mod smb; #[path = "form/webdav.rs"] mod webdav; +pub use gcs::{InputGcsBucket, InputGcsEndpoint, InputGcsServiceAccountKey}; pub use generic::{InputAddress, InputPassword, InputPort, InputUsername}; pub use kube::{ InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl, InputKubeNamespace, diff --git a/src/ui/activities/auth/components/form/gcs.rs b/src/ui/activities/auth/components/form/gcs.rs new file mode 100644 index 0000000..cfefe71 --- /dev/null +++ b/src/ui/activities/auth/components/form/gcs.rs @@ -0,0 +1,144 @@ +//! ## Google Cloud Storage Form +//! +//! Input components for Google Cloud Storage authentication parameters. + +use tuirealm::component::{AppComponent, Component}; + +use super::*; + +#[derive(Component)] +pub struct InputGcsBucket { + component: Input, + form_tab: FormTab, +} + +impl InputGcsBucket { + pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self { + Self { + component: Input::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .foreground(color) + .placeholder(tuirealm::props::SpanStatic::styled( + "my-bucket", + Style::default().fg(Color::Rgb(128, 128, 128)), + )) + .title(Title::from("Bucket").alignment(HorizontalAlignment::Left)) + .input_type(InputType::Text) + .value(bucket), + form_tab, + } + } +} + +impl AppComponent for InputGcsBucket { + fn on(&mut self, ev: &Event) -> Option { + let (on_key_down, on_key_up) = match self.form_tab { + FormTab::Remote => ( + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsBucketBlurDown)), + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsBucketBlurUp)), + ), + FormTab::HostBridge => ( + Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsBucketBlurDown)), + Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsBucketBlurUp)), + ), + }; + let form_tab = self.form_tab; + handle_input_ev(self, ev, on_key_down, on_key_up, form_tab) + } +} + +#[derive(Component)] +pub struct InputGcsEndpoint { + component: Input, + form_tab: FormTab, +} + +impl InputGcsEndpoint { + pub fn new(endpoint: &str, form_tab: FormTab, color: Color) -> Self { + Self { + component: Input::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .foreground(color) + .placeholder(tuirealm::props::SpanStatic::styled( + "https://storage.googleapis.com", + Style::default().fg(Color::Rgb(128, 128, 128)), + )) + .title(Title::from("Endpoint").alignment(HorizontalAlignment::Left)) + .input_type(InputType::Text) + .value(endpoint), + form_tab, + } + } +} + +impl AppComponent for InputGcsEndpoint { + fn on(&mut self, ev: &Event) -> Option { + let (on_key_down, on_key_up) = match self.form_tab { + FormTab::Remote => ( + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsEndpointBlurDown)), + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsEndpointBlurUp)), + ), + FormTab::HostBridge => ( + Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsEndpointBlurDown)), + Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsEndpointBlurUp)), + ), + }; + let form_tab = self.form_tab; + handle_input_ev(self, ev, on_key_down, on_key_up, form_tab) + } +} + +#[derive(Component)] +pub struct InputGcsServiceAccountKey { + component: Input, + form_tab: FormTab, +} + +impl InputGcsServiceAccountKey { + pub fn new(path: &str, form_tab: FormTab, color: Color) -> Self { + Self { + component: Input::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .foreground(color) + .placeholder(tuirealm::props::SpanStatic::styled( + "Optional service-account JSON path", + Style::default().fg(Color::Rgb(128, 128, 128)), + )) + .title(Title::from("Service account JSON").alignment(HorizontalAlignment::Left)) + .input_type(InputType::Text) + .value(path), + form_tab, + } + } +} + +impl AppComponent for InputGcsServiceAccountKey { + fn on(&mut self, ev: &Event) -> Option { + let (on_key_down, on_key_up) = match self.form_tab { + FormTab::Remote => ( + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsServiceAccountKeyBlurDown)), + Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsServiceAccountKeyBlurUp)), + ), + FormTab::HostBridge => ( + Msg::Ui(UiMsg::HostBridge( + UiAuthFormMsg::GcsServiceAccountKeyBlurDown, + )), + Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsServiceAccountKeyBlurUp)), + ), + }; + let form_tab = self.form_tab; + handle_input_ev(self, ev, on_key_down, on_key_up, form_tab) + } +} diff --git a/src/ui/activities/auth/components/form/protocol.rs b/src/ui/activities/auth/components/form/protocol.rs index af9d437..d6d0b2e 100644 --- a/src/ui/activities/auth/components/form/protocol.rs +++ b/src/ui/activities/auth/components/form/protocol.rs @@ -28,9 +28,12 @@ impl RemoteProtocolRadio { .modifiers(BorderType::Rounded), ) .choices(if cfg!(smb) { - vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"].into_iter() + vec![ + "SFTP", "SCP", "FTP", "FTPS", "S3", "GCS", "Kube", "WebDAV", "SMB", + ] + .into_iter() } else { - vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"].into_iter() + vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "GCS", "Kube", "WebDAV"].into_iter() }) .rewind(true) .title(Title::from("Protocol").alignment(HorizontalAlignment::Left)) @@ -44,6 +47,7 @@ impl RemoteProtocolRadio { REMOTE_RADIO_PROTOCOL_FTP => FileTransferProtocol::Ftp(false), REMOTE_RADIO_PROTOCOL_FTPS => FileTransferProtocol::Ftp(true), REMOTE_RADIO_PROTOCOL_S3 => FileTransferProtocol::AwsS3, + REMOTE_RADIO_PROTOCOL_GCS => FileTransferProtocol::GoogleCloudStorage, REMOTE_RADIO_PROTOCOL_SMB => FileTransferProtocol::Smb, REMOTE_RADIO_PROTOCOL_KUBE => FileTransferProtocol::Kube, REMOTE_RADIO_PROTOCOL_WEBDAV => FileTransferProtocol::WebDAV, @@ -58,6 +62,7 @@ impl RemoteProtocolRadio { FileTransferProtocol::Ftp(false) => REMOTE_RADIO_PROTOCOL_FTP, FileTransferProtocol::Ftp(true) => REMOTE_RADIO_PROTOCOL_FTPS, FileTransferProtocol::AwsS3 => REMOTE_RADIO_PROTOCOL_S3, + FileTransferProtocol::GoogleCloudStorage => REMOTE_RADIO_PROTOCOL_GCS, FileTransferProtocol::Kube => REMOTE_RADIO_PROTOCOL_KUBE, FileTransferProtocol::Smb => REMOTE_RADIO_PROTOCOL_SMB, FileTransferProtocol::WebDAV => REMOTE_RADIO_PROTOCOL_WEBDAV, @@ -128,6 +133,7 @@ impl HostBridgeProtocolRadio { "FTP", "FTPS", "S3", + "GCS", "Kube", "WebDAV", "SMB", @@ -141,6 +147,7 @@ impl HostBridgeProtocolRadio { "FTP", "FTPS", "S3", + "GCS", "Kube", "WebDAV", ] @@ -168,6 +175,9 @@ impl HostBridgeProtocolRadio { HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3) => { HOST_BRIDGE_RADIO_PROTOCOL_S3 } + HostBridgeProtocol::Remote(FileTransferProtocol::GoogleCloudStorage) => { + HOST_BRIDGE_RADIO_PROTOCOL_GCS + } HostBridgeProtocol::Remote(FileTransferProtocol::Smb) => HOST_BRIDGE_RADIO_PROTOCOL_SMB, HostBridgeProtocol::Remote(FileTransferProtocol::Kube) => { HOST_BRIDGE_RADIO_PROTOCOL_KUBE @@ -194,6 +204,9 @@ impl HostBridgeProtocolRadio { HOST_BRIDGE_RADIO_PROTOCOL_S3 => { HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3) } + HOST_BRIDGE_RADIO_PROTOCOL_GCS => { + HostBridgeProtocol::Remote(FileTransferProtocol::GoogleCloudStorage) + } HOST_BRIDGE_RADIO_PROTOCOL_SMB => HostBridgeProtocol::Remote(FileTransferProtocol::Smb), HOST_BRIDGE_RADIO_PROTOCOL_KUBE => { HostBridgeProtocol::Remote(FileTransferProtocol::Kube) diff --git a/src/ui/activities/auth/misc.rs b/src/ui/activities/auth/misc.rs index e011d7e..81ded4d 100644 --- a/src/ui/activities/auth/misc.rs +++ b/src/ui/activities/auth/misc.rs @@ -17,6 +17,7 @@ impl AuthActivity { FileTransferProtocol::Sftp | FileTransferProtocol::Scp => 22, FileTransferProtocol::Ftp(_) => 21, FileTransferProtocol::AwsS3 => 22, // Doesn't matter, since not used + FileTransferProtocol::GoogleCloudStorage => 22, // Doesn't matter, since not used FileTransferProtocol::Kube => 22, // Doesn't matter, since not used FileTransferProtocol::Smb => 445, FileTransferProtocol::WebDAV => 80, // Doesn't matter, since not used @@ -45,6 +46,9 @@ impl AuthActivity { HostBridgeProtocol::Remote(remote) => { let transfer_params = match remote { FileTransferProtocol::AwsS3 => self.collect_s3_host_params(FormTab::HostBridge), + FileTransferProtocol::GoogleCloudStorage => { + self.collect_gcs_host_params(FormTab::HostBridge) + } FileTransferProtocol::Kube => { self.collect_kube_host_params(FormTab::HostBridge) } @@ -71,6 +75,9 @@ impl AuthActivity { pub(super) fn collect_remote_host_params(&self) -> Result { match self.remote_protocol { FileTransferProtocol::AwsS3 => self.collect_s3_host_params(FormTab::Remote), + FileTransferProtocol::GoogleCloudStorage => { + self.collect_gcs_host_params(FormTab::Remote) + } FileTransferProtocol::Kube => self.collect_kube_host_params(FormTab::Remote), FileTransferProtocol::Smb => self.collect_smb_host_params(FormTab::Remote), FileTransferProtocol::Ftp(_) @@ -136,6 +143,26 @@ impl AuthActivity { }) } + /// Get input values from fields or return an error if fields are invalid to work as GCS. + pub(super) fn collect_gcs_host_params( + &self, + form_tab: FormTab, + ) -> Result { + let params = self.get_gcs_params_input(form_tab); + if params.bucket_name.is_empty() { + return Err("Invalid bucket"); + } + if params.endpoint.is_empty() { + return Err("Invalid endpoint"); + } + Ok(FileTransferParams { + protocol: FileTransferProtocol::GoogleCloudStorage, + params: ProtocolParams::GoogleCloudStorage(params), + local_path: self.get_input_local_directory(form_tab), + remote_path: self.get_input_remote_directory(form_tab), + }) + } + /// Get input values from fields or return an error if fields are invalid to work as aws s3 pub(super) fn collect_kube_host_params( &self, diff --git a/src/ui/activities/auth/update.rs b/src/ui/activities/auth/update.rs index 4b11934..b686c5e 100644 --- a/src/ui/activities/auth/update.rs +++ b/src/ui/activities/auth/update.rs @@ -86,6 +86,7 @@ impl AuthActivity { InputMask::Generic => &Id::Remote(AuthFormId::Password), InputMask::Smb => &Id::Remote(AuthFormId::Password), InputMask::AwsS3 => &Id::Remote(AuthFormId::S3Bucket), + InputMask::Gcs => &Id::Remote(AuthFormId::GcsBucket), InputMask::Kube => &Id::Remote(AuthFormId::KubeNamespace), InputMask::WebDAV => &Id::Remote(AuthFormId::Password), }, @@ -94,6 +95,7 @@ impl AuthActivity { InputMask::Generic => &Id::HostBridge(AuthFormId::Password), InputMask::Smb => &Id::HostBridge(AuthFormId::Password), InputMask::AwsS3 => &Id::HostBridge(AuthFormId::S3Bucket), + InputMask::Gcs => &Id::HostBridge(AuthFormId::GcsBucket), InputMask::Kube => &Id::HostBridge(AuthFormId::KubeNamespace), InputMask::WebDAV => &Id::HostBridge(AuthFormId::Password), }, @@ -112,6 +114,7 @@ impl AuthActivity { InputMask::Generic => &Id::Remote(AuthFormId::Password), InputMask::Smb => &Id::Remote(AuthFormId::Password), InputMask::AwsS3 => &Id::Remote(AuthFormId::S3Bucket), + InputMask::Gcs => &Id::Remote(AuthFormId::GcsBucket), InputMask::Kube => &Id::Remote(AuthFormId::KubeNamespace), InputMask::WebDAV => &Id::Remote(AuthFormId::Password), }, @@ -120,6 +123,7 @@ impl AuthActivity { InputMask::Generic => &Id::HostBridge(AuthFormId::Password), InputMask::Smb => &Id::HostBridge(AuthFormId::Password), InputMask::AwsS3 => &Id::HostBridge(AuthFormId::S3Bucket), + InputMask::Gcs => &Id::HostBridge(AuthFormId::GcsBucket), InputMask::Kube => &Id::HostBridge(AuthFormId::KubeNamespace), InputMask::WebDAV => &Id::HostBridge(AuthFormId::Password), }, @@ -280,6 +284,7 @@ impl AuthActivity { #[cfg(win)] InputMask::Smb => Id::HostBridge(AuthFormId::RemoteDirectory), InputMask::AwsS3 => unreachable!("this shouldn't happen (password on s3)"), + InputMask::Gcs => unreachable!("this shouldn't happen (password on gcs)"), InputMask::Kube => unreachable!("this shouldn't happen (password on kube)"), InputMask::WebDAV => Id::HostBridge(AuthFormId::RemoteDirectory), }; @@ -294,6 +299,7 @@ impl AuthActivity { InputMask::Smb => Id::HostBridge(AuthFormId::SmbShare), InputMask::Localhost | InputMask::AwsS3 + | InputMask::Gcs | InputMask::Kube | InputMask::WebDAV => { unreachable!("this shouldn't happen (port on s3/kube/webdav)") @@ -310,6 +316,7 @@ impl AuthActivity { InputMask::Generic => Id::HostBridge(AuthFormId::Address), InputMask::Smb => Id::HostBridge(AuthFormId::Address), InputMask::AwsS3 => Id::HostBridge(AuthFormId::S3Bucket), + InputMask::Gcs => Id::HostBridge(AuthFormId::GcsBucket), InputMask::Kube => Id::HostBridge(AuthFormId::KubeNamespace), InputMask::WebDAV => Id::HostBridge(AuthFormId::WebDAVUri), }; @@ -331,6 +338,7 @@ impl AuthActivity { InputMask::Smb => Id::HostBridge(AuthFormId::Password), InputMask::Kube => Id::HostBridge(AuthFormId::KubeClientKey), InputMask::AwsS3 => Id::HostBridge(AuthFormId::S3NewPathStyle), + InputMask::Gcs => Id::HostBridge(AuthFormId::GcsServiceAccountKey), InputMask::WebDAV => Id::HostBridge(AuthFormId::Password), }; self.activate_component(id); @@ -389,6 +397,24 @@ impl AuthActivity { UiAuthFormMsg::S3NewPathStyleBlurUp => { self.activate_component(Id::HostBridge(AuthFormId::S3SessionToken)) } + UiAuthFormMsg::GcsBucketBlurDown => { + self.activate_component(Id::HostBridge(AuthFormId::GcsEndpoint)) + } + UiAuthFormMsg::GcsBucketBlurUp => { + self.activate_component(Id::HostBridge(AuthFormId::Protocol)) + } + UiAuthFormMsg::GcsEndpointBlurDown => { + self.activate_component(Id::HostBridge(AuthFormId::GcsServiceAccountKey)) + } + UiAuthFormMsg::GcsEndpointBlurUp => { + self.activate_component(Id::HostBridge(AuthFormId::GcsBucket)) + } + UiAuthFormMsg::GcsServiceAccountKeyBlurDown => { + self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory)) + } + UiAuthFormMsg::GcsServiceAccountKeyBlurUp => { + self.activate_component(Id::HostBridge(AuthFormId::GcsEndpoint)) + } UiAuthFormMsg::KubeClientCertBlurDown => { self.activate_component(Id::HostBridge(AuthFormId::KubeClientKey)) } @@ -448,6 +474,7 @@ impl AuthActivity { InputMask::Smb => Id::HostBridge(AuthFormId::SmbShare), InputMask::Kube => unreachable!("this shouldn't happen (username on kube)"), InputMask::AwsS3 => unreachable!("this shouldn't happen (username on s3)"), + InputMask::Gcs => unreachable!("this shouldn't happen (username on gcs)"), InputMask::WebDAV => Id::HostBridge(AuthFormId::WebDAVUri), }; self.activate_component(id); @@ -496,6 +523,7 @@ impl AuthActivity { #[cfg(win)] InputMask::Smb => Id::Remote(AuthFormId::RemoteDirectory), InputMask::AwsS3 => unreachable!("this shouldn't happen (password on s3)"), + InputMask::Gcs => unreachable!("this shouldn't happen (password on gcs)"), InputMask::Kube => unreachable!("this shouldn't happen (password on kube)"), InputMask::WebDAV => Id::Remote(AuthFormId::RemoteDirectory), }; @@ -510,6 +538,7 @@ impl AuthActivity { InputMask::Smb => Id::Remote(AuthFormId::SmbShare), InputMask::Localhost | InputMask::AwsS3 + | InputMask::Gcs | InputMask::Kube | InputMask::WebDAV => { unreachable!("this shouldn't happen (port on s3/kube/webdav)") @@ -526,6 +555,7 @@ impl AuthActivity { InputMask::Generic => Id::Remote(AuthFormId::Address), InputMask::Smb => Id::Remote(AuthFormId::Address), InputMask::AwsS3 => Id::Remote(AuthFormId::S3Bucket), + InputMask::Gcs => Id::Remote(AuthFormId::GcsBucket), InputMask::Kube => Id::Remote(AuthFormId::KubeNamespace), InputMask::WebDAV => Id::Remote(AuthFormId::WebDAVUri), }; @@ -547,6 +577,7 @@ impl AuthActivity { InputMask::Smb => Id::Remote(AuthFormId::Password), InputMask::Kube => Id::Remote(AuthFormId::KubeClientKey), InputMask::AwsS3 => Id::Remote(AuthFormId::S3NewPathStyle), + InputMask::Gcs => Id::Remote(AuthFormId::GcsServiceAccountKey), InputMask::WebDAV => Id::Remote(AuthFormId::Password), }; self.activate_component(id); @@ -605,6 +636,24 @@ impl AuthActivity { UiAuthFormMsg::S3NewPathStyleBlurUp => { self.activate_component(Id::Remote(AuthFormId::S3SessionToken)) } + UiAuthFormMsg::GcsBucketBlurDown => { + self.activate_component(Id::Remote(AuthFormId::GcsEndpoint)) + } + UiAuthFormMsg::GcsBucketBlurUp => { + self.activate_component(Id::Remote(AuthFormId::Protocol)) + } + UiAuthFormMsg::GcsEndpointBlurDown => { + self.activate_component(Id::Remote(AuthFormId::GcsServiceAccountKey)) + } + UiAuthFormMsg::GcsEndpointBlurUp => { + self.activate_component(Id::Remote(AuthFormId::GcsBucket)) + } + UiAuthFormMsg::GcsServiceAccountKeyBlurDown => { + self.activate_component(Id::Remote(AuthFormId::RemoteDirectory)) + } + UiAuthFormMsg::GcsServiceAccountKeyBlurUp => { + self.activate_component(Id::Remote(AuthFormId::GcsEndpoint)) + } UiAuthFormMsg::KubeClientCertBlurDown => { self.activate_component(Id::Remote(AuthFormId::KubeClientKey)) } @@ -664,6 +713,7 @@ impl AuthActivity { InputMask::Smb => Id::Remote(AuthFormId::SmbShare), InputMask::Kube => unreachable!("this shouldn't happen (username on kube)"), InputMask::AwsS3 => unreachable!("this shouldn't happen (username on s3)"), + InputMask::Gcs => unreachable!("this shouldn't happen (username on gcs)"), InputMask::WebDAV => Id::Remote(AuthFormId::WebDAVUri), }; self.activate_component(id); diff --git a/src/ui/activities/auth/view.rs b/src/ui/activities/auth/view.rs index e771190..3ca1ed7 100644 --- a/src/ui/activities/auth/view.rs +++ b/src/ui/activities/auth/view.rs @@ -11,6 +11,7 @@ use super::{ AuthActivity, AuthFormId, Context, FileTransferProtocol, FormTab, HostBridgeProtocol, Id, InputMask, components, }; +use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT; use crate::utils::ui::{Popup, Size}; #[path = "view/mounting.rs"] @@ -52,6 +53,9 @@ impl AuthActivity { self.mount_port(FormTab::HostBridge, 22); self.mount_username(FormTab::HostBridge, ""); self.mount_password(FormTab::HostBridge, ""); + self.mount_gcs_bucket(FormTab::HostBridge, ""); + self.mount_gcs_endpoint(FormTab::HostBridge, DEFAULT_GCS_ENDPOINT); + self.mount_gcs_service_account_key(FormTab::HostBridge, ""); self.mount_s3_bucket(FormTab::HostBridge, ""); self.mount_s3_profile(FormTab::HostBridge, ""); self.mount_s3_region(FormTab::HostBridge, ""); @@ -72,6 +76,7 @@ impl AuthActivity { self.mount_webdav_uri(FormTab::HostBridge, ""); let remote_default_protocol = self.context().config().get_default_protocol(); + self.set_remote_protocol(remote_default_protocol); self.mount_remote_protocol(remote_default_protocol); self.mount_remote_directory(FormTab::Remote, ""); self.mount_local_directory(FormTab::Remote, ""); @@ -82,6 +87,9 @@ impl AuthActivity { ); self.mount_username(FormTab::Remote, ""); self.mount_password(FormTab::Remote, ""); + self.mount_gcs_bucket(FormTab::Remote, ""); + self.mount_gcs_endpoint(FormTab::Remote, DEFAULT_GCS_ENDPOINT); + self.mount_gcs_service_account_key(FormTab::Remote, ""); self.mount_s3_bucket(FormTab::Remote, ""); self.mount_s3_profile(FormTab::Remote, ""); self.mount_s3_region(FormTab::Remote, ""); @@ -264,6 +272,7 @@ impl AuthActivity { .split(protocol_and_mask_chunks[1]); match self.host_bridge_input_mask() { InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_host_bridge_s3_view()), + InputMask::Gcs => self.render_view_ids(f, input_mask, self.get_host_bridge_gcs_view()), InputMask::Generic => { self.render_view_ids(f, input_mask, self.get_host_bridge_generic_params_view()) } @@ -311,6 +320,7 @@ impl AuthActivity { .split(protocol_and_mask_chunks[1]); match self.remote_input_mask() { InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_remote_s3_view()), + InputMask::Gcs => self.render_view_ids(f, input_mask, self.get_remote_gcs_view()), InputMask::Generic => { self.render_view_ids(f, input_mask, self.get_remote_generic_params_view()) } diff --git a/src/ui/activities/auth/view/mounting.rs b/src/ui/activities/auth/view/mounting.rs index af9ba12..4405136 100644 --- a/src/ui/activities/auth/view/mounting.rs +++ b/src/ui/activities/auth/view/mounting.rs @@ -383,6 +383,56 @@ impl AuthActivity { } } + pub(in crate::ui::activities::auth) fn mount_gcs_bucket( + &mut self, + form_tab: FormTab, + bucket: &str, + ) { + let color = self.theme().auth_address; + let id = Self::form_tab_id(form_tab, AuthFormId::GcsBucket); + if let Err(err) = self.app.remount( + id, + Box::new(components::InputGcsBucket::new(bucket, form_tab, color)), + vec![], + ) { + error!("Failed to remount component: {err}"); + } + } + + pub(in crate::ui::activities::auth) fn mount_gcs_endpoint( + &mut self, + form_tab: FormTab, + endpoint: &str, + ) { + let color = self.theme().auth_username; + let id = Self::form_tab_id(form_tab, AuthFormId::GcsEndpoint); + if let Err(err) = self.app.remount( + id, + Box::new(components::InputGcsEndpoint::new(endpoint, form_tab, color)), + vec![], + ) { + error!("Failed to remount component: {err}"); + } + } + + pub(in crate::ui::activities::auth) fn mount_gcs_service_account_key( + &mut self, + form_tab: FormTab, + path: &str, + ) { + let color = self.theme().auth_password; + let id = Self::form_tab_id(form_tab, AuthFormId::GcsServiceAccountKey); + if let Err(err) = self.app.remount( + id, + Box::new(components::InputGcsServiceAccountKey::new( + path, form_tab, color, + )), + vec![], + ) { + error!("Failed to remount component: {err}"); + } + } + pub(in crate::ui::activities::auth) fn mount_s3_bucket( &mut self, form_tab: FormTab, diff --git a/src/ui/activities/auth/view/query.rs b/src/ui/activities/auth/view/query.rs index b9509e8..b03b620 100644 --- a/src/ui/activities/auth/view/query.rs +++ b/src/ui/activities/auth/view/query.rs @@ -6,8 +6,8 @@ use tuirealm::state::{State, StateValue}; use super::*; use crate::filetransfer::FileTransferParams; use crate::filetransfer::params::{ - AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, SmbParams, - WebDAVProtocolParams, + AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams, + ProtocolParams, SmbParams, WebDAVProtocolParams, }; impl AuthActivity { @@ -48,6 +48,15 @@ impl AuthActivity { .new_path_style(new_path_style) } + pub(in crate::ui::activities::auth) fn get_gcs_params_input( + &self, + form_tab: FormTab, + ) -> GoogleCloudStorageParams { + GoogleCloudStorageParams::new(self.get_input_gcs_bucket(form_tab)) + .endpoint(self.get_input_gcs_endpoint(form_tab)) + .service_account_key(self.get_input_gcs_service_account_key(form_tab)) + } + pub(in crate::ui::activities::auth) fn get_kube_params_input( &self, form_tab: FormTab, @@ -213,6 +222,45 @@ impl AuthActivity { } } + pub(in crate::ui::activities::auth) fn get_input_gcs_bucket( + &self, + form_tab: FormTab, + ) -> String { + match self + .app + .state(&Self::form_tab_id(form_tab, AuthFormId::GcsBucket)) + { + Ok(State::Single(StateValue::String(value))) => value, + _ => String::new(), + } + } + + pub(in crate::ui::activities::auth) fn get_input_gcs_endpoint( + &self, + form_tab: FormTab, + ) -> String { + match self + .app + .state(&Self::form_tab_id(form_tab, AuthFormId::GcsEndpoint)) + { + Ok(State::Single(StateValue::String(value))) => value, + _ => String::new(), + } + } + + pub(in crate::ui::activities::auth) fn get_input_gcs_service_account_key( + &self, + form_tab: FormTab, + ) -> Option { + match self.app.state(&Self::form_tab_id( + form_tab, + AuthFormId::GcsServiceAccountKey, + )) { + Ok(State::Single(StateValue::String(value))) if !value.is_empty() => Some(value), + _ => None, + } + } + pub(in crate::ui::activities::auth) fn get_input_s3_region( &self, form_tab: FormTab, @@ -428,6 +476,7 @@ impl AuthActivity { fn input_mask_size(input_mask: InputMask) -> u16 { match input_mask { InputMask::AwsS3 + | InputMask::Gcs | InputMask::Generic | InputMask::Kube | InputMask::Smb @@ -446,6 +495,11 @@ impl AuthActivity { pub(in crate::ui::activities::auth) fn fmt_recent(b: FileTransferParams) -> String { let protocol = b.protocol.to_string().to_lowercase(); + let remote_path = b + .remote_path + .as_ref() + .map(|path| format!(" {}", path.display())) + .unwrap_or_default(); match b.params { ProtocolParams::AwsS3(s3) => { let profile = match s3.profile { @@ -471,6 +525,15 @@ impl AuthActivity { protocol, username, params.address, params.port ) } + ProtocolParams::GoogleCloudStorage(params) => format!( + "{protocol}://{} ({}){remote_path}", + params.bucket_name, + if params.endpoint.is_empty() { + crate::filetransfer::params::DEFAULT_GCS_ENDPOINT + } else { + params.endpoint.as_str() + }, + ), ProtocolParams::Kube(params) => { format!( "{}://{}{}", diff --git a/src/ui/activities/auth/view/visible.rs b/src/ui/activities/auth/view/visible.rs index fa37d73..0e9d776 100644 --- a/src/ui/activities/auth/view/visible.rs +++ b/src/ui/activities/auth/view/visible.rs @@ -159,6 +159,40 @@ impl AuthActivity { } } + pub(in crate::ui::activities::auth) fn get_host_bridge_gcs_view(&self) -> [Id; 4] { + match self.app.focus() { + Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [ + Id::HostBridge(AuthFormId::GcsEndpoint), + Id::HostBridge(AuthFormId::GcsServiceAccountKey), + Id::HostBridge(AuthFormId::RemoteDirectory), + Id::HostBridge(AuthFormId::LocalDirectory), + ], + _ => [ + Id::HostBridge(AuthFormId::GcsBucket), + Id::HostBridge(AuthFormId::GcsEndpoint), + Id::HostBridge(AuthFormId::GcsServiceAccountKey), + Id::HostBridge(AuthFormId::RemoteDirectory), + ], + } + } + + pub(in crate::ui::activities::auth) fn get_remote_gcs_view(&self) -> [Id; 4] { + match self.app.focus() { + Some(&Id::Remote(AuthFormId::LocalDirectory)) => [ + Id::Remote(AuthFormId::GcsEndpoint), + Id::Remote(AuthFormId::GcsServiceAccountKey), + Id::Remote(AuthFormId::RemoteDirectory), + Id::Remote(AuthFormId::LocalDirectory), + ], + _ => [ + Id::Remote(AuthFormId::GcsBucket), + Id::Remote(AuthFormId::GcsEndpoint), + Id::Remote(AuthFormId::GcsServiceAccountKey), + Id::Remote(AuthFormId::RemoteDirectory), + ], + } + } + pub(in crate::ui::activities::auth) fn get_host_bridge_kube_view(&self) -> [Id; 4] { match self.app.focus() { Some(&Id::HostBridge(AuthFormId::KubeClientCert)) => [ diff --git a/src/ui/activities/filetransfer/misc/host.rs b/src/ui/activities/filetransfer/misc/host.rs index 9bcaee9..dbc4092 100644 --- a/src/ui/activities/filetransfer/misc/host.rs +++ b/src/ui/activities/filetransfer/misc/host.rs @@ -64,6 +64,7 @@ impl FileTransferActivity { match params { ProtocolParams::Generic(params) => params.address.clone(), ProtocolParams::AwsS3(params) => params.bucket_name.clone(), + ProtocolParams::GoogleCloudStorage(params) => params.bucket_name.clone(), ProtocolParams::Kube(params) => { params.namespace.clone().unwrap_or("default".to_string()) } @@ -93,6 +94,11 @@ impl FileTransferActivity { ); format!("Connecting to {}…", params.bucket_name) } + ProtocolParams::GoogleCloudStorage(params) => format!( + "Connecting to GCS bucket '{bucket}' at {endpoint}…", + bucket = params.bucket_name, + endpoint = params.endpoint, + ), ProtocolParams::Kube(params) => { let namespace = params.namespace.as_deref().unwrap_or("default"); info!("Client is not connected to remote; connecting to namespace {namespace}",); diff --git a/src/ui/activities/filetransfer/session/transfer.rs b/src/ui/activities/filetransfer/session/transfer.rs index 2898683..83666a2 100644 --- a/src/ui/activities/filetransfer/session/transfer.rs +++ b/src/ui/activities/filetransfer/session/transfer.rs @@ -32,6 +32,10 @@ enum TransferErrorReason { RemoteHostError(HostError), } +fn handle_remote_finalize_result(result: Result<(), HostError>) -> Result<(), TransferErrorReason> { + result.map_err(TransferErrorReason::RemoteHostError) +} + /// Represents the entity to send or receive during a transfer. /// - File: describes an individual `File` to send /// - Any: Can be any kind of `File`, but just one @@ -525,12 +529,7 @@ impl FileTransferActivity { } } // Finalize stream - if let Err(err) = self.browser.remote_pane_mut().fs.finalize_write(writer) { - self.log( - LogLevel::Warn, - format!("Could not finalize remote stream: \"{err}\""), - ); - } + handle_remote_finalize_result(self.browser.remote_pane_mut().fs.finalize_write(writer))?; // if upload was abrupted, return error if self.transfer.aborted() { return Err(TransferErrorReason::Abrupted); @@ -922,6 +921,24 @@ impl FileTransferActivity { } } +#[cfg(test)] +mod finalize_test { + use super::*; + use crate::host::HostErrorType; + + #[test] + fn should_propagate_remote_finalize_error() { + let error = HostError::from(HostErrorType::CouldNotCreateFile); + + let result = handle_remote_finalize_result(Err(error)); + + assert!(matches!( + result, + Err(TransferErrorReason::RemoteHostError(_)) + )); + } +} + #[cfg(test)] mod worklist_test { use std::time::SystemTime; diff --git a/src/ui/activities/setup.rs b/src/ui/activities/setup.rs index 14718a1..40488fd 100644 --- a/src/ui/activities/setup.rs +++ b/src/ui/activities/setup.rs @@ -32,8 +32,9 @@ const RADIO_PROTOCOL_FTP: usize = 2; const RADIO_PROTOCOL_FTPS: usize = 3; const RADIO_PROTOCOL_KUBE: usize = 4; const RADIO_PROTOCOL_S3: usize = 5; -const RADIO_PROTOCOL_SMB: usize = 6; -const RADIO_PROTOCOL_WEBDAV: usize = 7; +const RADIO_PROTOCOL_GCS: usize = 6; +const RADIO_PROTOCOL_SMB: usize = 7; +const RADIO_PROTOCOL_WEBDAV: usize = 8; // -- components #[derive(Debug, Eq, PartialEq, Clone, Hash)] diff --git a/src/ui/activities/setup/components/config.rs b/src/ui/activities/setup/components/config.rs index 31d3aa9..a3574ff 100644 --- a/src/ui/activities/setup/components/config.rs +++ b/src/ui/activities/setup/components/config.rs @@ -14,8 +14,9 @@ use super::{ConfigMsg, Msg}; use crate::explorer::GroupDirs as GroupDirsEnum; use crate::filetransfer::FileTransferProtocol; use crate::ui::activities::setup::{ - RADIO_PROTOCOL_FTP, RADIO_PROTOCOL_FTPS, RADIO_PROTOCOL_KUBE, RADIO_PROTOCOL_S3, - RADIO_PROTOCOL_SCP, RADIO_PROTOCOL_SFTP, RADIO_PROTOCOL_SMB, RADIO_PROTOCOL_WEBDAV, + RADIO_PROTOCOL_FTP, RADIO_PROTOCOL_FTPS, RADIO_PROTOCOL_GCS, RADIO_PROTOCOL_KUBE, + RADIO_PROTOCOL_S3, RADIO_PROTOCOL_SCP, RADIO_PROTOCOL_SFTP, RADIO_PROTOCOL_SMB, + RADIO_PROTOCOL_WEBDAV, }; use crate::utils::parser::parse_bytesize; @@ -73,7 +74,9 @@ impl DefaultProtocol { .color(Color::Cyan) .modifiers(BorderType::Rounded), ) - .choices(["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"]) + .choices([ + "SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "GCS", "SMB", "WebDAV", + ]) .foreground(Color::Cyan) .rewind(true) .title(Title::from("Default protocol").alignment(HorizontalAlignment::Left)) @@ -84,6 +87,7 @@ impl DefaultProtocol { FileTransferProtocol::Ftp(true) => RADIO_PROTOCOL_FTPS, FileTransferProtocol::Kube => RADIO_PROTOCOL_KUBE, FileTransferProtocol::AwsS3 => RADIO_PROTOCOL_S3, + FileTransferProtocol::GoogleCloudStorage => RADIO_PROTOCOL_GCS, FileTransferProtocol::Smb => RADIO_PROTOCOL_SMB, FileTransferProtocol::WebDAV => RADIO_PROTOCOL_WEBDAV, }), diff --git a/src/ui/activities/setup/view/setup.rs b/src/ui/activities/setup/view/setup.rs index 31c0b75..c736b15 100644 --- a/src/ui/activities/setup/view/setup.rs +++ b/src/ui/activities/setup/view/setup.rs @@ -12,8 +12,8 @@ use tuirealm::state::{State, StateValue}; use tuirealm::terminal::TerminalAdapter; use super::{ - Context, Id, IdCommon, IdConfig, RADIO_PROTOCOL_KUBE, RADIO_PROTOCOL_WEBDAV, SetupActivity, - ViewLayout, components, + Context, Id, IdCommon, IdConfig, RADIO_PROTOCOL_GCS, RADIO_PROTOCOL_KUBE, + RADIO_PROTOCOL_WEBDAV, SetupActivity, ViewLayout, components, }; use crate::explorer::GroupDirs; use crate::filetransfer::FileTransferProtocol; @@ -272,6 +272,7 @@ impl SetupActivity { RADIO_PROTOCOL_FTPS => FileTransferProtocol::Ftp(true), RADIO_PROTOCOL_KUBE => FileTransferProtocol::Kube, RADIO_PROTOCOL_S3 => FileTransferProtocol::AwsS3, + RADIO_PROTOCOL_GCS => FileTransferProtocol::GoogleCloudStorage, RADIO_PROTOCOL_SMB => FileTransferProtocol::Smb, RADIO_PROTOCOL_WEBDAV => FileTransferProtocol::WebDAV, _ => FileTransferProtocol::Sftp, diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 2860eeb..f9d0fc1 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -40,6 +40,9 @@ pub(super) static REMOTE_KUBE_OPT_REGEX: Lazy = pub(super) static REMOTE_S3_OPT_REGEX: Lazy = lazy_regex!(r"(?:(.+[^@])@)(?:([^:]+))(?::([a-zA-Z0-9][^:]+))?(?::([^:]+))?"); +/// Regex matches Google Cloud Storage remote options. +pub(super) static REMOTE_GCS_OPT_REGEX: Lazy = lazy_regex!(r"^([^:]+)(?::(/.*))?$"); + /// Regex matches SMB remote options on Unix platforms. #[cfg(smb_unix)] pub(super) static REMOTE_SMB_OPT_REGEX: Lazy = lazy_regex!( @@ -82,6 +85,10 @@ static BYTESIZE_REGEX: Lazy = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B /// /// `s3://@[:profile][:/wrkdir]` /// +/// For Google Cloud Storage: +/// +/// `gcs://[:/working/directory]` +/// /// For SMB: /// /// on UNIX derived (macos, linux, ...) @@ -472,6 +479,33 @@ mod tests { assert_eq!(params.region.as_deref().unwrap(), "eu-central-1"); } + #[test] + fn should_parse_google_cloud_storage_address() { + let result = parse_remote_opt("gcs://my-bucket").unwrap(); + let params = result.params.gcs_params().unwrap(); + + assert_eq!(result.protocol, FileTransferProtocol::GoogleCloudStorage); + assert_eq!(result.remote_path, None); + assert_eq!(params.bucket_name, "my-bucket"); + assert_eq!(params.endpoint, "https://storage.googleapis.com"); + assert_eq!(params.service_account_key, None); + } + + #[test] + fn should_parse_google_cloud_storage_working_directory() { + let result = parse_remote_opt("gcs://my-bucket:/assets/images").unwrap(); + + assert_eq!( + result.remote_path.as_deref(), + Some(std::path::Path::new("/assets/images")) + ); + } + + #[test] + fn should_reject_google_cloud_storage_address_without_bucket() { + assert!(parse_remote_opt("gcs://:/assets").is_err()); + } + #[test] fn should_parse_kube_address() { let result = parse_remote_opt("kube://my-namespace@http://localhost:1234$/tmp") diff --git a/src/utils/parser/ports.rs b/src/utils/parser/ports.rs index 4e25d56..43fbb4a 100644 --- a/src/utils/parser/ports.rs +++ b/src/utils/parser/ports.rs @@ -9,6 +9,7 @@ pub(super) fn default_port_for_protocol(protocol: FileTransferProtocol) -> u16 { match protocol { FileTransferProtocol::Ftp(_) => 21, FileTransferProtocol::Scp | FileTransferProtocol::Sftp => 22, + FileTransferProtocol::GoogleCloudStorage => 22, _ => 22, } } diff --git a/src/utils/parser/remote.rs b/src/utils/parser/remote.rs index f2e1724..f0eeed0 100644 --- a/src/utils/parser/remote.rs +++ b/src/utils/parser/remote.rs @@ -11,12 +11,14 @@ use super::credentials::{optional_capture, required_capture}; use super::ports::{default_port_for_protocol, parse_port}; use super::protocol::parse_remote_opt_protocol; use super::{ - REMOTE_GENERIC_OPT_REGEX, REMOTE_KUBE_OPT_REGEX, REMOTE_S3_OPT_REGEX, REMOTE_WEBDAV_OPT_REGEX, + REMOTE_GCS_OPT_REGEX, REMOTE_GENERIC_OPT_REGEX, REMOTE_KUBE_OPT_REGEX, REMOTE_S3_OPT_REGEX, + REMOTE_WEBDAV_OPT_REGEX, }; #[cfg(smb)] use crate::filetransfer::params::SmbParams; use crate::filetransfer::params::{ - AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, WebDAVProtocolParams, + AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams, + ProtocolParams, WebDAVProtocolParams, }; use crate::filetransfer::{FileTransferParams, FileTransferProtocol}; #[cfg(not(test))] @@ -30,6 +32,7 @@ pub(super) fn parse_remote_opt(s: &str) -> Result { match protocol { FileTransferProtocol::AwsS3 => parse_s3_remote_opt(remote.as_str()), + FileTransferProtocol::GoogleCloudStorage => parse_gcs_remote_opt(remote.as_str()), FileTransferProtocol::Kube => parse_kube_remote_opt(remote.as_str()), #[cfg(smb)] FileTransferProtocol::Smb => parse_smb_remote_opts(remote.as_str()), @@ -122,6 +125,20 @@ fn parse_s3_remote_opt(s: &str) -> Result { .remote_path(remote_path)) } +fn parse_gcs_remote_opt(s: &str) -> Result { + let groups = REMOTE_GCS_OPT_REGEX + .captures(s) + .ok_or_else(|| String::from("Bad Google Cloud Storage syntax!"))?; + let bucket = required_capture(&groups, 1, "bucket")?; + let remote_path = groups.get(2).map(|group| PathBuf::from(group.as_str())); + + Ok(FileTransferParams::new( + FileTransferProtocol::GoogleCloudStorage, + ProtocolParams::GoogleCloudStorage(GoogleCloudStorageParams::new(bucket)), + ) + .remote_path(remote_path)) +} + fn parse_kube_remote_opt(s: &str) -> Result { let groups = REMOTE_KUBE_OPT_REGEX .captures(s)