use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::process::Command;
use crate::config::Config;
use crate::proton_pass::ProtonPass;
#[derive(Debug, Clone)]
pub struct RcloneEntry {
pub remote_name: String,
pub host: String,
pub user: String,
pub key_file: String,
pub other_aliases: String,
}
pub fn sync_remotes(
entries: &[RcloneEntry],
config: &Config,
full_mode: bool,
dry_run: bool,
log: &impl Fn(&str),
) -> Result<()> {
if which::which("rclone").is_err() {
return Ok(());
}
if entries.is_empty() {
return Ok(());
}
log("");
log("Syncing rclone remotes...");
if !config.rclone.password_path.is_empty() {
let proton_pass = ProtonPass::new();
match proton_pass.get_item_field(&config.rclone.password_path, "password") {
Ok(password) => {
std::env::set_var("RCLONE_CONFIG_PASS", password);
}
Err(_) => {
log(" (skipped - could not get rclone password)");
return Ok(());
}
}
}
let mut current_config = get_rclone_config()?;
if full_mode {
let managed_remotes: Vec<String> = current_config
.iter()
.filter(|(_, remote)| {
remote.description.as_deref() == Some("managed by pass-ssh-unpack")
})
.map(|(name, _)| name.clone())
.collect();
for remote_name in &managed_remotes {
if dry_run {
log(&format!(" Would delete remote: {}", remote_name));
} else {
delete_remote(remote_name)?;
}
}
if !dry_run {
current_config = get_rclone_config()?;
}
}
let mut created_count = 0;
let mut skipped_count = 0;
for entry in entries {
if entry.remote_name.is_empty() {
continue;
}
if let Some(existing) = current_config.get(&entry.remote_name) {
if existing.description.as_deref() != Some("managed by pass-ssh-unpack") {
log(&format!(
" Skipping {}: existing unmanaged remote",
entry.remote_name
));
skipped_count += 1;
continue;
}
}
if dry_run {
if current_config.contains_key(&entry.remote_name) {
log(&format!(" {} (exists)", entry.remote_name));
} else {
log(&format!(
" Would create SFTP remote: {}",
entry.remote_name
));
}
} else if !entry.key_file.is_empty() {
create_sftp_remote(
&entry.remote_name,
&entry.host,
&entry.user,
Some(&entry.key_file),
)?;
} else {
create_sftp_remote(&entry.remote_name, &entry.host, &entry.user, None)?;
}
created_count += 1;
if !entry.other_aliases.is_empty() {
for alias_name in entry
.other_aliases
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
if alias_name == entry.remote_name {
continue;
}
if let Some(existing) = current_config.get(alias_name) {
if existing.description.as_deref() != Some("managed by pass-ssh-unpack") {
log(&format!(
" Skipping alias {}: existing unmanaged remote",
alias_name
));
skipped_count += 1;
continue;
}
}
if dry_run {
if current_config.contains_key(alias_name) {
log(&format!(" {} (exists)", alias_name));
} else {
log(&format!(
" Would create alias remote: {} -> {}",
alias_name, entry.remote_name
));
}
} else {
create_alias_remote(alias_name, &entry.remote_name)?;
}
created_count += 1;
}
}
}
let mut pruned_count = 0;
if !dry_run {
let updated_config = get_rclone_config()?;
let home_dir = dirs::home_dir().unwrap_or_default();
let sftp_to_prune: Vec<String> = updated_config
.iter()
.filter(|(_, remote)| {
remote.remote_type == "sftp"
&& remote.description.as_deref() == Some("managed by pass-ssh-unpack")
&& remote
.key_file
.as_ref()
.map(|kf| {
let expanded = kf.replace("~", &home_dir.to_string_lossy());
!std::path::Path::new(&expanded).exists()
})
.unwrap_or(false)
})
.map(|(name, _)| name.clone())
.collect();
for remote_name in &sftp_to_prune {
delete_remote(remote_name)?;
pruned_count += 1;
}
let updated_config = get_rclone_config()?;
let alias_to_prune: Vec<String> = updated_config
.iter()
.filter(|(_, remote)| {
remote.remote_type == "alias"
&& remote.description.as_deref() == Some("managed by pass-ssh-unpack")
&& remote
.remote
.as_ref()
.map(|r| {
let target = r.trim_end_matches(':');
!updated_config.contains_key(target)
})
.unwrap_or(false)
})
.map(|(name, _)| name.clone())
.collect();
for remote_name in alias_to_prune {
delete_remote(&remote_name)?;
pruned_count += 1;
}
}
if dry_run {
log(&format!(" Would sync {} remotes.", created_count));
} else {
log(&format!(" Synced {} remotes.", created_count));
}
if skipped_count > 0 {
log(&format!(
" Skipped {} (unmanaged conflicts).",
skipped_count
));
}
if pruned_count > 0 {
log(&format!(" Pruned {} orphaned remotes.", pruned_count));
}
Ok(())
}
pub fn purge_managed_remotes(config: &Config, dry_run: bool, log: &impl Fn(&str)) -> Result<()> {
if which::which("rclone").is_err() {
log(" (rclone not installed)");
return Ok(());
}
if !config.rclone.password_path.is_empty() {
let proton_pass = ProtonPass::new();
if let Ok(password) = proton_pass.get_item_field(&config.rclone.password_path, "password") {
std::env::set_var("RCLONE_CONFIG_PASS", password);
} else {
log(" (skipped rclone - could not get password)");
return Ok(());
}
}
let current_config = get_rclone_config()?;
let managed_remotes: Vec<String> = current_config
.iter()
.filter(|(_, remote)| remote.description.as_deref() == Some("managed by pass-ssh-unpack"))
.map(|(name, _)| name.clone())
.collect();
let deleted_count = managed_remotes.len();
for remote_name in &managed_remotes {
if dry_run {
log(&format!(" Would remove remote: {}", remote_name));
} else {
delete_remote(remote_name)?;
}
}
if deleted_count > 0 {
if dry_run {
log(&format!(" Would remove {} rclone remotes", deleted_count));
} else {
log(&format!(" Removed {} rclone remotes", deleted_count));
}
} else {
log(" No managed rclone remotes found");
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct RcloneRemote {
#[serde(rename = "type")]
remote_type: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
key_file: Option<String>,
#[serde(default)]
remote: Option<String>,
}
fn get_rclone_config() -> Result<HashMap<String, RcloneRemote>> {
let output = Command::new("rclone")
.args(["config", "dump"])
.output()
.context("Failed to run rclone config dump")?;
if !output.status.success() || output.stdout.is_empty() {
return Ok(HashMap::new());
}
let config: HashMap<String, RcloneRemote> =
serde_json::from_slice(&output.stdout).unwrap_or_default();
Ok(config)
}
fn create_sftp_remote(name: &str, host: &str, user: &str, key_file: Option<&str>) -> Result<()> {
let mut args = vec![
"config".to_string(),
"create".to_string(),
name.to_string(),
"sftp".to_string(),
format!("host={}", host),
format!("user={}", user),
];
if let Some(kf) = key_file {
args.push(format!("key_file={}", kf));
} else {
args.push("ask_password=true".to_string());
}
args.push("description=managed by pass-ssh-unpack".to_string());
Command::new("rclone")
.args(&args)
.output()
.context("Failed to create rclone SFTP remote")?;
Ok(())
}
fn create_alias_remote(name: &str, target: &str) -> Result<()> {
Command::new("rclone")
.args([
"config",
"create",
name,
"alias",
&format!("remote={}:", target),
"description=managed by pass-ssh-unpack",
])
.output()
.context("Failed to create rclone alias remote")?;
Ok(())
}
fn delete_remote(name: &str) -> Result<()> {
Command::new("rclone")
.args(["config", "delete", name])
.output()
.context("Failed to delete rclone remote")?;
Ok(())
}