From d88f0843b5f4ed797924718a79b4e8b9406e0f29 Mon Sep 17 00:00:00 2001 From: rain-bus Date: Thu, 4 Jun 2026 00:01:15 +0800 Subject: [PATCH] refactor: remove S3 sync feature and related settings --- Cargo.lock | 12 -- Cargo.toml | 3 - README.md | 2 +- mise.toml | 1 + src/app/home_ops.rs | 2 - src/app/settings.rs | 56 ------ src/cli.rs | 8 +- src/config.rs | 5 - src/sync.rs | 1 - src/sync/s3.rs | 313 ------------------------------ src/ui/component/custom/header.rs | 9 +- src/ui/view/settings.rs | 7 +- 12 files changed, 6 insertions(+), 413 deletions(-) delete mode 100644 src/sync/s3.rs diff --git a/Cargo.lock b/Cargo.lock index 60bf047..274743c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,15 +836,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.4.0" @@ -2299,14 +2290,11 @@ dependencies = [ "clap", "crossterm", "dirs", - "hex", - "hmac", "indexmap", "ratatui", "reqwest", "serde", "serde_json", - "sha2", "tempfile", "toml", "whoami", diff --git a/Cargo.toml b/Cargo.toml index cd34a25..c3e87c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,11 @@ base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crossterm = "0.29" dirs = "6" -hex = "0.4" -hmac = "0.12" indexmap = { version = "2", features = ["serde"] } ratatui = { version = "0.30", features = ["crossterm_0_29"] } reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.10" tempfile = "3.27" toml = "0.9" whoami = "1.6" diff --git a/README.md b/README.md index 16c04d7..b7b6615 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A personal SSH and shell connection manager with a terminal user interface (TUI) - **Import from `~/.ssh/config`** — One command to import all existing hosts - **Local shell scan** — Auto-detects available shells from `/etc/shells` - **Quick select** — Press `Ctrl+Q` then a number key (1–9) to connect instantly -- **Encrypted cloud sync** — Push/pull config via GitHub Gist, WebDAV, or S3 with AES-256-GCM encryption +- **Encrypted cloud sync** — Push/pull config via GitHub Gist or WebDAV with AES-256-GCM encryption - **CLI subcommands** — Connect, import, sync, and diagnostics without launching the TUI ## Installation diff --git a/mise.toml b/mise.toml index 95d23b8..e6fb6a3 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,3 @@ [tools] rust = "latest" +rust-analyzer = "latest" diff --git a/src/app/home_ops.rs b/src/app/home_ops.rs index 0bf3144..854a523 100644 --- a/src/app/home_ops.rs +++ b/src/app/home_ops.rs @@ -67,7 +67,6 @@ impl App { let result = match self.config.settings.backend { SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")), SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()), - SyncBackend::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()), }; match result { Ok(msg) => self.toast(msg, true), @@ -79,7 +78,6 @@ impl App { let result = match self.config.settings.backend { SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge), SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge), - SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge), }; match result { Ok(count) => self.toast(format!("pulled {count} items"), true), diff --git a/src/app/settings.rs b/src/app/settings.rs index 73a3a21..e675f28 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -9,10 +9,6 @@ pub enum SettingsField { WebdavUrl, WebdavUser, WebdavPassword, - S3Endpoint, - S3Bucket, - S3AccessKey, - S3SecretKey, } impl SettingsField { @@ -27,14 +23,6 @@ impl SettingsField { SettingsField::WebdavPassword, ]); } - SyncBackend::S3 => { - fields.extend_from_slice(&[ - SettingsField::S3Endpoint, - SettingsField::S3Bucket, - SettingsField::S3AccessKey, - SettingsField::S3SecretKey, - ]); - } } fields } @@ -47,10 +35,6 @@ impl SettingsField { Self::WebdavUrl => "URL", Self::WebdavUser => "Username", Self::WebdavPassword => "Password", - Self::S3Endpoint => "Endpoint", - Self::S3Bucket => "Bucket", - Self::S3AccessKey => "Access Key", - Self::S3SecretKey => "Secret Key", } } @@ -71,10 +55,6 @@ pub struct SettingsState { pub webdav_url: String, pub webdav_user: String, pub webdav_password: String, - pub s3_endpoint: String, - pub s3_bucket: String, - pub s3_access_key: String, - pub s3_secret_key: String, pub active: SettingsField, pub cursor: usize, } @@ -87,10 +67,6 @@ impl SettingsState { SettingsField::WebdavUrl => &self.webdav_url, SettingsField::WebdavUser => &self.webdav_user, SettingsField::WebdavPassword => &self.webdav_password, - SettingsField::S3Endpoint => &self.s3_endpoint, - SettingsField::S3Bucket => &self.s3_bucket, - SettingsField::S3AccessKey => &self.s3_access_key, - SettingsField::S3SecretKey => &self.s3_secret_key, _ => "", } } @@ -140,10 +116,6 @@ impl Default for SettingsState { webdav_url: String::new(), webdav_user: String::new(), webdav_password: String::new(), - s3_endpoint: String::new(), - s3_bucket: String::new(), - s3_access_key: String::new(), - s3_secret_key: String::new(), active: SettingsField::SyncPassword, cursor: 0, } @@ -158,10 +130,6 @@ impl TextEditing for SettingsState { SettingsField::WebdavUrl => &self.webdav_url, SettingsField::WebdavUser => &self.webdav_user, SettingsField::WebdavPassword => &self.webdav_password, - SettingsField::S3Endpoint => &self.s3_endpoint, - SettingsField::S3Bucket => &self.s3_bucket, - SettingsField::S3AccessKey => &self.s3_access_key, - SettingsField::S3SecretKey => &self.s3_secret_key, _ => "", } } @@ -173,10 +141,6 @@ impl TextEditing for SettingsState { SettingsField::WebdavUrl => Some(&mut self.webdav_url), SettingsField::WebdavUser => Some(&mut self.webdav_user), SettingsField::WebdavPassword => Some(&mut self.webdav_password), - SettingsField::S3Endpoint => Some(&mut self.s3_endpoint), - SettingsField::S3Bucket => Some(&mut self.s3_bucket), - SettingsField::S3AccessKey => Some(&mut self.s3_access_key), - SettingsField::S3SecretKey => Some(&mut self.s3_secret_key), _ => None, } } @@ -212,14 +176,6 @@ impl App { self.config.settings.webdav_user.clone().unwrap_or_default(); self.session.settings.webdav_password = self.config.settings.webdav_password.clone().unwrap_or_default(); - self.session.settings.s3_endpoint = - self.config.settings.s3_endpoint.clone().unwrap_or_default(); - self.session.settings.s3_bucket = - self.config.settings.s3_bucket.clone().unwrap_or_default(); - self.session.settings.s3_access_key = - self.config.settings.s3_access_key.clone().unwrap_or_default(); - self.session.settings.s3_secret_key = - self.config.settings.s3_secret_key.clone().unwrap_or_default(); self.session.settings.active = SettingsField::SyncPassword; self.session.settings.cursor = char_len(self.session.settings.active_text()); self.session.mode = Mode::Settings; @@ -241,18 +197,6 @@ impl App { } else { Some(wd_pw) }; - let s3_ep = self.session.settings.s3_endpoint.trim().to_string(); - self.config.settings.s3_endpoint = if s3_ep.is_empty() { None } else { Some(s3_ep) }; - let s3_bk = self.session.settings.s3_bucket.trim().to_string(); - self.config.settings.s3_bucket = if s3_bk.is_empty() { None } else { Some(s3_bk) }; - let s3_ak = self.session.settings.s3_access_key.trim().to_string(); - self.config.settings.s3_access_key = if s3_ak.is_empty() { None } else { Some(s3_ak) }; - let s3_sk = self.session.settings.s3_secret_key.trim().to_string(); - self.config.settings.s3_secret_key = if s3_sk.is_empty() { - None - } else { - Some(s3_sk) - }; self.config.save()?; self.session.mode = Mode::Home; Ok(()) diff --git a/src/cli.rs b/src/cli.rs index bf1a78d..4fb6312 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary}; -use crate::sync::{self, gist, s3, webdav}; +use crate::sync::{self, gist, webdav}; use crate::{connection, import, ui}; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; @@ -88,16 +88,11 @@ fn run_sync(command: SyncCommand) -> Result<()> { webdav::push(&mut cfg)?; println!("pushed"); } - SyncBackend::S3 => { - s3::push(&mut cfg)?; - println!("pushed"); - } }, SyncCommand::Pull { strategy } => { let count = match cfg.settings.backend { SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?, SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?, - SyncBackend::S3 => s3::pull_with_strategy(&mut cfg, strat(strategy))?, }; println!("pulled {count} items"); } @@ -124,7 +119,6 @@ fn doctor(name: Option) -> Result<()> { match cfg.settings.backend { SyncBackend::Gist => "gist", SyncBackend::Webdav => "webdav", - SyncBackend::S3 => "s3", } ); println!( diff --git a/src/config.rs b/src/config.rs index 140651a..b367c41 100644 --- a/src/config.rs +++ b/src/config.rs @@ -49,7 +49,6 @@ pub enum SyncBackend { #[default] Gist, Webdav, - S3, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -60,10 +59,6 @@ pub struct Settings { pub webdav_url: Option, pub webdav_user: Option, pub webdav_password: Option, - pub s3_endpoint: Option, - pub s3_bucket: Option, - pub s3_access_key: Option, - pub s3_secret_key: Option, pub sync_password: Option, } diff --git a/src/sync.rs b/src/sync.rs index 4d21f77..ca71cda 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,6 +1,5 @@ mod crypto; pub mod gist; -pub mod s3; pub mod webdav; use crate::config::ConnectionType; diff --git a/src/sync/s3.rs b/src/sync/s3.rs deleted file mode 100644 index 424bebd..0000000 --- a/src/sync/s3.rs +++ /dev/null @@ -1,313 +0,0 @@ -use crate::config::SshellConfig; -use super::{PullStrategy, build_sync_payload, merge_remote}; -use anyhow::{Context, Result, bail}; -use hmac::{Hmac, Mac}; -use reqwest::blocking::Client; -use reqwest::header::{CONTENT_TYPE, HOST}; -use sha2::{Digest, Sha256}; - -type HmacSha256 = Hmac; - -const FILE_NAME: &str = "sshell-config.toml"; -const SERVICE: &str = "s3"; - -pub fn push(cfg: &mut SshellConfig) -> Result { - let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?; - let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?; - let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?; - let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?; - let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?; - let body = toml::to_string_pretty(&payload)?; - let body_bytes = body.as_bytes(); - - let path = format!("/{bucket}/{FILE_NAME}"); - let host = endpoint_host(endpoint); - - let now = chrono_now(); - let region = region_from_endpoint(endpoint); - - let payload_hash = hex_hash(body_bytes); - let (auth_header, amz_date) = sign( - access_key, - secret_key, - ®ion, - &SigningRequest { - method: "PUT", - host: &host, - path: &path, - query: &[], - payload_hash: &payload_hash, - timestamp: &now, - }, - ); - - let url = format!("https://{host}{path}"); - - let client = Client::new(); - let response = client - .put(&url) - .header(HOST, host.clone()) - .header(CONTENT_TYPE, "application/octet-stream") - .header("x-amz-content-sha256", &payload_hash) - .header("x-amz-date", &amz_date) - .header("Authorization", &auth_header) - .body(body) - .send()?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().unwrap_or_default(); - bail!("sync push failed: {status} {body}"); - } - - Ok(url) -} - -pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result { - let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?; - let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?; - let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?; - let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?; - - let path = format!("/{bucket}/{FILE_NAME}"); - let host = endpoint_host(endpoint); - - let now = chrono_now(); - let region = region_from_endpoint(endpoint); - - let payload_hash = hex_hash(b""); - let (auth_header, amz_date) = sign( - access_key, - secret_key, - ®ion, - &SigningRequest { - method: "GET", - host: &host, - path: &path, - query: &[], - payload_hash: &payload_hash, - timestamp: &now, - }, - ); - - let url = format!("https://{host}{path}"); - - let client = Client::new(); - let response = client - .get(&url) - .header(HOST, host.clone()) - .header("x-amz-content-sha256", &payload_hash) - .header("x-amz-date", &amz_date) - .header("Authorization", &auth_header) - .send()?; - - if response.status() == reqwest::StatusCode::NOT_FOUND { - bail!("sync pull failed: remote file not found"); - } - if !response.status().is_success() { - let status = response.status(); - let body = response.text().unwrap_or_default(); - bail!("sync pull failed: {status} {body}"); - } - - let content = response.text()?; - let remote: toml::Value = - toml::from_str(&content).with_context(|| "failed to parse remote config")?; - - merge_remote(cfg, remote, strategy) -} - -// ── AWS Signature V4 ─────────────────────────────────────────── - -struct SigningRequest<'a> { - method: &'a str, - host: &'a str, - path: &'a str, - query: &'a [(&'a str, &'a str)], - payload_hash: &'a str, - timestamp: &'a str, -} - -fn sign( - access_key: &str, - secret_key: &str, - region: &str, - req: &SigningRequest<'_>, -) -> (String, String) { - let date = &req.timestamp[..8]; - let amz_date = req.timestamp.to_string(); - - let content_type_val = if req.method == "PUT" { - "application/octet-stream" - } else { - "" - }; - - // Canonical headers (sorted by key) - let mut headers: Vec<(&str, String)> = vec![ - ("content-type", content_type_val.to_string()), - ("host", req.host.to_string()), - ("x-amz-content-sha256", req.payload_hash.to_string()), - ("x-amz-date", amz_date.clone()), - ]; - headers.sort_by_key(|(k, _)| *k); - - let signed_headers: String = headers.iter().map(|(k, _)| *k).collect::>().join(";"); - let canonical_headers: String = headers - .iter() - .map(|(k, v)| format!("{k}:{v}\n")) - .collect(); - - let canonical_querystring = req.query - .iter() - .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v))) - .collect::>() - .join("&"); - - let canonical_request = format!( - "{method}\n{path}\n{qs}\n{headers}\n{signed}\n{hash}", - method = req.method, - path = req.path, - qs = canonical_querystring, - headers = canonical_headers, - signed = signed_headers, - hash = req.payload_hash, - ); - - let credential_scope = format!("{date}/{region}/{SERVICE}/aws4_request"); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}", - timestamp = req.timestamp, - scope = credential_scope, - hash = hex_hash(canonical_request.as_bytes()), - ); - - let signing_key = derive_signing_key(secret_key, date, region); - let signature = hex_hmac(&signing_key, string_to_sign.as_bytes()); - - let auth = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}" - ); - - (auth, amz_date) -} - -fn derive_signing_key(secret_key: &str, date: &str, region: &str) -> Vec { - let k_date = hmac_bytes(format!("AWS4{secret_key}").as_bytes(), date.as_bytes()); - let k_region = hmac_bytes(&k_date, region.as_bytes()); - let k_service = hmac_bytes(&k_region, SERVICE.as_bytes()); - hmac_bytes(&k_service, b"aws4_request") -} - -fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec { - let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key len"); - mac.update(data); - mac.finalize().into_bytes().to_vec() -} - -fn hex_hmac(key: &[u8], data: &[u8]) -> String { - hex::encode(hmac_bytes(key, data)) -} - -fn hex_hash(data: &[u8]) -> String { - hex::encode(Sha256::digest(data)) -} - -fn url_encode(s: &str) -> String { - let mut out = String::new(); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => { - out.push(b as char) - } - _ => out.push_str(&format!("%{b:02X}")), - } - } - out -} - -fn endpoint_host(endpoint: &str) -> String { - let s = endpoint - .strip_prefix("https://") - .or_else(|| endpoint.strip_prefix("http://")) - .unwrap_or(endpoint); - s.trim_end_matches('/').to_string() -} - -fn region_from_endpoint(endpoint: &str) -> String { - if endpoint.contains("r2.cloudflarestorage.com") { - return "auto".to_string(); - } - let host = endpoint_host(endpoint); - let parts: Vec<&str> = host.split('.').collect(); - for (i, part) in parts.iter().enumerate() { - if *part == "s3" && i + 1 < parts.len() { - return parts[i + 1].to_string(); - } - } - "us-east-1".to_string() -} - -fn chrono_now() -> String { - let now = std::time::SystemTime::now(); - let duration = now - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let secs = duration.as_secs(); - // Simple UTC time formatting without chrono dependency - let days = secs / 86400; - let time_of_day = secs % 86400; - let hours = time_of_day / 3600; - let minutes = (time_of_day % 3600) / 60; - let seconds = time_of_day % 60; - - // Calculate year/month/day from days since epoch - let (year, month, day) = days_to_date(days); - - format!( - "{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z" - ) -} - -fn days_to_date(mut days: u64) -> (u64, u64, u64) { - let mut year = 1970u64; - loop { - let days_in_year = if is_leap(year) { 366 } else { 365 }; - if days < days_in_year { - break; - } - days -= days_in_year; - year += 1; - } - let leap = is_leap(year); - let month_days = [ - 31, - if leap { 29 } else { 28 }, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - let mut month = 0u64; - for (i, &md) in month_days.iter().enumerate() { - if days < md { - month = i as u64 + 1; - break; - } - days -= md; - } - if month == 0 { - month = 12; - } - (year, month, days + 1) -} - -fn is_leap(year: u64) -> bool { - (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) -} diff --git a/src/ui/component/custom/header.rs b/src/ui/component/custom/header.rs index 76ea6d2..bc4c79c 100644 --- a/src/ui/component/custom/header.rs +++ b/src/ui/component/custom/header.rs @@ -1,6 +1,6 @@ use crate::app::App; use crate::config::SyncBackend; -use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT}; +use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, TEXT}; use ratatui::{ layout::{Alignment, Rect}, style::{Color, Style, Stylize}, @@ -31,13 +31,6 @@ pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, title: &str, area: ("webdav not set", MUTED) } } - SyncBackend::S3 => { - if app.config.settings.s3_endpoint.is_some() { - ("s3 ready", PURPLE) - } else { - ("s3 not set", MUTED) - } - } }; let mut spans: Vec> = vec![ diff --git a/src/ui/view/settings.rs b/src/ui/view/settings.rs index d172f3d..ca449ba 100644 --- a/src/ui/view/settings.rs +++ b/src/ui/view/settings.rs @@ -1,7 +1,7 @@ use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len}; use crate::config::SyncBackend; use crate::ui::component::{FormRow, badge_span}; -use crate::ui::{ACCENT, ORANGE, PURPLE}; +use crate::ui::{ACCENT, ORANGE}; use super::{View, handle_form_nav}; @@ -40,7 +40,6 @@ impl View for SettingsView { SettingsField::Backend => match settings.backend { SyncBackend::Gist => badge_span("Gist", ACCENT), SyncBackend::Webdav => badge_span("WebDAV", ORANGE), - SyncBackend::S3 => badge_span("S3", PURPLE), }, _ => unreachable!(), }; @@ -55,7 +54,6 @@ impl View for SettingsView { field, SettingsField::SyncPassword | SettingsField::WebdavPassword - | SettingsField::S3SecretKey ); let (display, secret_cursor) = if is_secret { if raw.is_empty() { @@ -119,8 +117,7 @@ fn settings_toggle(settings: &mut SettingsState) { SettingsField::Backend => { settings.backend = match settings.backend { SyncBackend::Gist => SyncBackend::Webdav, - SyncBackend::Webdav => SyncBackend::S3, - SyncBackend::S3 => SyncBackend::Gist, + SyncBackend::Webdav => SyncBackend::Gist, }; settings.ensure_active_visible(); }