diff --git a/src/app/home_ops.rs b/src/app/home_ops.rs index 3962a5a..0bf3144 100644 --- a/src/app/home_ops.rs +++ b/src/app/home_ops.rs @@ -77,9 +77,9 @@ impl App { pub fn pull_sync_with_toast(&mut self) { let result = match self.config.settings.backend { - SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), - SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), - SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::gist::PullStrategy::Merge), + 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/cli.rs b/src/cli.rs index 9f86dab..bf1a78d 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::{gist, s3, webdav}; +use crate::sync::{self, gist, s3, webdav}; use crate::{connection, import, ui}; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; @@ -75,8 +75,8 @@ pub fn run() -> Result<()> { fn run_sync(command: SyncCommand) -> Result<()> { let mut cfg = SshellConfig::load()?; let strat = |s: PullStrategy| match s { - PullStrategy::Merge => gist::PullStrategy::Merge, - PullStrategy::Overwrite => gist::PullStrategy::Overwrite, + PullStrategy::Merge => sync::PullStrategy::Merge, + PullStrategy::Overwrite => sync::PullStrategy::Overwrite, }; match command { SyncCommand::Push => match cfg.settings.backend { diff --git a/src/sync.rs b/src/sync.rs index 84c35f6..c822d4e 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,3 +1,204 @@ +mod crypto; pub mod gist; pub mod s3; pub mod webdav; + +use crate::config::ConnectionType; +use crate::config::{ConnectionSource, CredentialStore, SshellConfig}; +use anyhow::{Context, Result}; + +pub(crate) const GIST_TOKEN_REF: &str = "__gist_token"; + +#[derive(Debug, Clone, Copy)] +pub enum PullStrategy { + Merge, + Overwrite, +} + +pub(crate) fn localize_shell_profile( + cfg: &SshellConfig, + name: &str, + profile: &mut crate::config::ConnectionProfile, +) -> bool { + let ConnectionType::Shell { + shell_name, + command, + local_args, + auth_ref, + .. + } = &mut profile.kind + else { + return true; + }; + + local_args.clear(); + *auth_ref = None; + + let Some(local_command) = cfg.local_shell_command(shell_name) else { + return false; + }; + *command = local_command; + + if let Some(local_profile) = cfg.connections.get(name) { + profile.local_tags = local_profile.local_tags.clone(); + profile.added_order = local_profile.added_order; + profile.usage_count = local_profile.usage_count; + if let ConnectionType::Shell { + local_args: existing_local_args, + .. + } = &local_profile.kind + { + *local_args = existing_local_args.clone(); + } + } + + true +} + +pub(crate) fn build_sync_payload( + cfg: &SshellConfig, + sync_password: Option<&str>, +) -> Result { + let mut payload = cfg.clone(); + payload.settings.sync_password = None; + payload.settings.webdav_password = None; + payload.settings.s3_secret_key = None; + + let synced_refs: Vec = payload + .connections + .iter() + .filter(|(_, profile)| profile.sync()) + .filter_map(|(_, profile)| profile.auth_ref().map(String::from)) + .collect(); + + payload.credentials.entries.retain(|name, _| { + name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name) + }); + + let encrypted = if !payload.credentials.entries.is_empty() { + if let Some(pw) = sync_password { + Some(crypto::encrypt_credentials(&payload.credentials, pw)?) + } else { + None + } + } else { + None + }; + + let mut table = toml::map::Map::new(); + table.insert( + "version".to_string(), + toml::Value::Integer(payload.version as i64), + ); + table.insert("settings".to_string(), to_toml_value(&payload.settings)?); + + let mut conns = toml::map::Map::new(); + for (name, profile) in &mut payload.connections { + profile.local_tags.clear(); + if !payload.settings.sync_usage_count { + profile.usage_count = 0; + } + match &mut profile.kind { + ConnectionType::Shell { + auth_ref, + command, + shell_name: _, + local_args, + sync, + .. + } => { + if !*sync { + continue; + } + local_args.clear(); + *command = String::new(); + *auth_ref = None; + } + ConnectionType::Ssh { sync, .. } => { + if !*sync { + continue; + } + profile.source = ConnectionSource::Manual; + } + } + conns.insert(name.clone(), to_toml_value(&*profile)?); + } + table.insert("connections".to_string(), toml::Value::Table(conns)); + + if let Some(enc) = encrypted { + table.insert( + "credentials_encrypted".to_string(), + toml::Value::String(enc), + ); + } else if !payload.credentials.entries.is_empty() { + table.insert( + "credentials".to_string(), + to_toml_value(&payload.credentials)?, + ); + } + + Ok(toml::Value::Table(table)) +} + +pub(crate) fn merge_remote( + cfg: &mut SshellConfig, + remote: toml::Value, + strategy: PullStrategy, +) -> Result { + let mut count = 0; + + if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) { + for (name, profile_val) in conns { + let should_insert = match strategy { + PullStrategy::Merge => !cfg.connections.contains_key(name), + PullStrategy::Overwrite => true, + }; + if should_insert + && let Ok(mut profile) = profile_val + .clone() + .try_into::() + && localize_shell_profile(cfg, name, &mut profile) + { + cfg.connections.insert(name.clone(), profile); + count += 1; + } + } + } + + let remote_credentials = + if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) { + let sync_password = cfg + .settings + .sync_password + .as_deref() + .context("sync_password not set; needed to decrypt remote credentials")?; + Some(crypto::decrypt_credentials(enc, sync_password)?) + } else if let Some(creds_val) = remote.get("credentials") { + creds_val.clone().try_into::().ok() + } else { + None + }; + + if let Some(remote_creds) = remote_credentials { + for (name, credential) in remote_creds.entries { + if name == GIST_TOKEN_REF { + continue; + } + let should_insert = match strategy { + PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name), + PullStrategy::Overwrite => true, + }; + if should_insert { + cfg.credentials.entries.insert(name, credential); + count += 1; + } + } + } + + cfg.save()?; + Ok(count) +} + +pub(crate) fn to_toml_value(val: &T) -> Result { + toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}")) +} diff --git a/src/sync/gist/crypto.rs b/src/sync/crypto.rs similarity index 100% rename from src/sync/gist/crypto.rs rename to src/sync/crypto.rs diff --git a/src/sync/gist.rs b/src/sync/gist.rs index 60fdd24..742d44c 100644 --- a/src/sync/gist.rs +++ b/src/sync/gist.rs @@ -1,19 +1,10 @@ -use crate::config::ConnectionType; -use crate::config::{ConnectionSource, CredentialEntry, CredentialStore, SshellConfig}; +use crate::config::{CredentialEntry, SshellConfig}; +use super::{GIST_TOKEN_REF, PullStrategy, build_sync_payload, merge_remote}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use serde_json::json; -mod crypto; - const FILE_NAME: &str = "sshell-config.toml"; -pub(crate) const GIST_TOKEN_REF: &str = "__gist_token"; - -#[derive(Debug, Clone, Copy)] -pub enum PullStrategy { - Merge, - Overwrite, -} pub fn push(cfg: &mut SshellConfig) -> Result { let token = gist_token(cfg)?; @@ -96,188 +87,3 @@ fn gist_token(cfg: &SshellConfig) -> Result { _ => bail!("set GITHUB_TOKEN or create password credential __gist_token"), } } - -pub(crate) fn localize_shell_profile( - cfg: &SshellConfig, - name: &str, - profile: &mut crate::config::ConnectionProfile, -) -> bool { - let ConnectionType::Shell { - shell_name, - command, - local_args, - auth_ref, - .. - } = &mut profile.kind - else { - return true; - }; - - local_args.clear(); - *auth_ref = None; - - let Some(local_command) = cfg.local_shell_command(shell_name) else { - return false; - }; - *command = local_command; - - if let Some(local_profile) = cfg.connections.get(name) { - profile.local_tags = local_profile.local_tags.clone(); - profile.added_order = local_profile.added_order; - profile.usage_count = local_profile.usage_count; - if let ConnectionType::Shell { - local_args: existing_local_args, - .. - } = &local_profile.kind - { - *local_args = existing_local_args.clone(); - } - } - - true -} - -pub(crate) fn build_sync_payload(cfg: &SshellConfig, sync_password: Option<&str>) -> Result { - let mut payload = cfg.clone(); - payload.settings.sync_password = None; - payload.settings.webdav_password = None; - payload.settings.s3_secret_key = None; - - // Collect auth_refs from synced connections - let synced_refs: Vec = payload - .connections - .iter() - .filter(|(_, profile)| profile.sync()) - .filter_map(|(_, profile)| profile.auth_ref().map(String::from)) - .collect(); - - payload.credentials.entries.retain(|name, _| { - name != GIST_TOKEN_REF && synced_refs.iter().any(|r| r == name) - }); - - let encrypted = if !payload.credentials.entries.is_empty() { - if let Some(pw) = sync_password { - Some(crypto::encrypt_credentials(&payload.credentials, pw)?) - } else { - None - } - } else { - None - }; - - let mut table = toml::map::Map::new(); - table.insert( - "version".to_string(), - toml::Value::Integer(payload.version as i64), - ); - table.insert("settings".to_string(), to_toml_value(&payload.settings)?); - - let mut conns = toml::map::Map::new(); - for (name, profile) in &mut payload.connections { - profile.local_tags.clear(); - if !payload.settings.sync_usage_count { - profile.usage_count = 0; - } - match &mut profile.kind { - ConnectionType::Shell { - auth_ref, - command, - shell_name: _, - local_args, - sync, - .. - } => { - if !*sync { - continue; - } - local_args.clear(); - *command = String::new(); - *auth_ref = None; - } - ConnectionType::Ssh { sync, .. } => { - if !*sync { - continue; - } - profile.source = ConnectionSource::Manual; - } - } - conns.insert(name.clone(), to_toml_value(&*profile)?); - } - table.insert("connections".to_string(), toml::Value::Table(conns)); - - if let Some(enc) = encrypted { - table.insert( - "credentials_encrypted".to_string(), - toml::Value::String(enc), - ); - } else if !payload.credentials.entries.is_empty() { - table.insert("credentials".to_string(), to_toml_value(&payload.credentials)?); - } - - Ok(toml::Value::Table(table)) -} - -pub(crate) fn merge_remote( - cfg: &mut SshellConfig, - remote: toml::Value, - strategy: PullStrategy, -) -> Result { - let mut count = 0; - - // Merge connections - if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) { - for (name, profile_val) in conns { - let should_insert = match strategy { - PullStrategy::Merge => !cfg.connections.contains_key(name), - PullStrategy::Overwrite => true, - }; - if should_insert - && let Ok(mut profile) = profile_val - .clone() - .try_into::() - && localize_shell_profile(cfg, name, &mut profile) - { - cfg.connections.insert(name.clone(), profile); - count += 1; - } - } - } - - // Merge credentials: try encrypted first, then plaintext fallback - let remote_credentials = - if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) { - let sync_password = cfg - .settings - .sync_password - .as_deref() - .context("sync_password not set; needed to decrypt remote credentials")?; - Some(crypto::decrypt_credentials(enc, sync_password)?) - } else if let Some(creds_val) = remote.get("credentials") { - creds_val.clone().try_into::().ok() - } else { - None - }; - - if let Some(remote_creds) = remote_credentials { - for (name, credential) in remote_creds.entries { - if name == GIST_TOKEN_REF { - continue; - } - let should_insert = match strategy { - PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name), - PullStrategy::Overwrite => true, - }; - if should_insert { - cfg.credentials.entries.insert(name, credential); - count += 1; - } - } - } - - cfg.save()?; - Ok(count) -} - -pub(crate) fn to_toml_value(val: &T) -> Result { - toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}")) -} diff --git a/src/sync/s3.rs b/src/sync/s3.rs index 5eb87fd..424bebd 100644 --- a/src/sync/s3.rs +++ b/src/sync/s3.rs @@ -1,5 +1,5 @@ use crate::config::SshellConfig; -use super::gist::{PullStrategy, build_sync_payload, merge_remote}; +use super::{PullStrategy, build_sync_payload, merge_remote}; use anyhow::{Context, Result, bail}; use hmac::{Hmac, Mac}; use reqwest::blocking::Client; diff --git a/src/sync/webdav.rs b/src/sync/webdav.rs index 7802659..12948c3 100644 --- a/src/sync/webdav.rs +++ b/src/sync/webdav.rs @@ -1,5 +1,5 @@ use crate::config::SshellConfig; -use super::gist::{PullStrategy, build_sync_payload, merge_remote}; +use super::{PullStrategy, build_sync_payload, merge_remote}; use anyhow::{Context, Result, bail}; use reqwest::blocking::Client; use reqwest::header::{ACCEPT, CONTENT_TYPE};