refactor: remove S3 sync feature and related settings

This commit is contained in:
2026-06-04 00:01:15 +08:00
parent 24af4d0f95
commit d88f0843b5
12 changed files with 6 additions and 413 deletions
Generated
-12
View File
@@ -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",
-3
View File
@@ -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"
+1 -1
View File
@@ -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 (19) 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
+1
View File
@@ -1,2 +1,3 @@
[tools]
rust = "latest"
rust-analyzer = "latest"
-2
View File
@@ -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),
-56
View File
@@ -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(())
+1 -7
View File
@@ -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<String>) -> Result<()> {
match cfg.settings.backend {
SyncBackend::Gist => "gist",
SyncBackend::Webdav => "webdav",
SyncBackend::S3 => "s3",
}
);
println!(
-5
View File
@@ -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<String>,
pub webdav_user: Option<String>,
pub webdav_password: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_bucket: Option<String>,
pub s3_access_key: Option<String>,
pub s3_secret_key: Option<String>,
pub sync_password: Option<String>,
}
-1
View File
@@ -1,6 +1,5 @@
mod crypto;
pub mod gist;
pub mod s3;
pub mod webdav;
use crate::config::ConnectionType;
-313
View File
@@ -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<Sha256>;
const FILE_NAME: &str = "sshell-config.toml";
const SERVICE: &str = "s3";
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
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,
&region,
&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<usize> {
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,
&region,
&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::<Vec<_>>().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::<Vec<_>>()
.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<u8> {
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<u8> {
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)
}
+1 -8
View File
@@ -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<Span<'static>> = vec![
+2 -5
View File
@@ -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();
}