use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ReencryptReport {
pub config_rewritten: bool,
pub accounts_total: usize,
pub accounts_rewritten: usize,
pub accounts_failed: usize,
pub failed_accounts: Vec<String>,
}
fn reencrypt_all_secrets_from_data_dir(data_dir: &Path) -> Result<ReencryptReport, String> {
let config_path = data_dir.join("config.json");
let config = if config_path.exists() {
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("failed_to_read_config_for_reencryption: {}", e))?;
serde_json::from_str::<crate::models::AppConfig>(&config_content)
.map_err(|e| format!("failed_to_parse_config_for_reencryption: {}", e))?
} else {
crate::models::AppConfig::default()
};
let rewritten_config = serde_json::to_string_pretty(&config)
.map_err(|e| format!("failed_to_serialize_config_for_reencryption: {}", e))?;
fs::write(&config_path, rewritten_config)
.map_err(|e| format!("failed_to_write_config_for_reencryption: {}", e))?;
let accounts_dir = data_dir.join("accounts");
fs::create_dir_all(&accounts_dir)
.map_err(|e| format!("failed_to_prepare_accounts_dir_for_reencryption: {}", e))?;
let mut report = ReencryptReport {
config_rewritten: true,
accounts_total: 0,
accounts_rewritten: 0,
accounts_failed: 0,
failed_accounts: Vec::new(),
};
let entries = fs::read_dir(&accounts_dir)
.map_err(|e| format!("failed_to_read_accounts_dir_for_reencryption: {}", e))?;
for entry in entries {
let entry = match entry {
Ok(v) => v,
Err(e) => {
report.accounts_failed += 1;
report
.failed_accounts
.push(format!("read_dir_entry_error: {}", e));
continue;
}
};
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|v| v.to_str()) != Some("json") {
continue;
}
let Some(account_id) = path
.file_stem()
.and_then(|v| v.to_str())
.map(|v| v.to_string())
else {
report.accounts_failed += 1;
report.failed_accounts.push(format!(
"invalid_account_file_name: {}",
path.to_string_lossy()
));
continue;
};
report.accounts_total += 1;
let rewrite_result = fs::read_to_string(&path)
.map_err(|e| {
format!(
"failed_to_read_account_for_reencryption({account_id}): {}",
e
)
})
.and_then(|content| {
serde_json::from_str::<crate::models::Account>(&content).map_err(|e| {
format!(
"failed_to_parse_account_for_reencryption({account_id}): {}",
e
)
})
})
.and_then(|account| {
serde_json::to_string_pretty(&account).map_err(|e| {
format!(
"failed_to_serialize_account_for_reencryption({account_id}): {}",
e
)
})
})
.and_then(|rewritten| {
fs::write(&path, rewritten).map_err(|e| {
format!(
"failed_to_write_account_for_reencryption({account_id}): {}",
e
)
})
});
if rewrite_result.is_ok() {
report.accounts_rewritten += 1;
} else {
report.accounts_failed += 1;
report.failed_accounts.push(account_id);
}
}
if report.accounts_failed > 0 {
return Err(format!(
"secret_reencryption_completed_with_failures: failed={} accounts={:?}",
report.accounts_failed, report.failed_accounts
));
}
Ok(report)
}
pub fn reencrypt_all_secrets() -> Result<ReencryptReport, String> {
let data_dir = crate::modules::auth::account::get_data_dir()?;
reencrypt_all_secrets_from_data_dir(&data_dir)
}
#[cfg(test)]
mod tests {
use super::reencrypt_all_secrets_from_data_dir;
use crate::models::{Account, TokenData};
use crate::proxy::config::{ProxyAuth, ProxyEntry, ProxySelectionStrategy};
use crate::test_utils::{lock_env, ScopedEnvVar};
use std::sync::{Mutex, OnceLock};
static REENCRYPT_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn build_test_account(id: &str, email: &str) -> Account {
let token = TokenData::new(
"access-token".to_string(),
"refresh-token".to_string(),
3600,
Some(email.to_string()),
None,
None,
);
Account::new(id.to_string(), email.to_string(), token)
}
#[test]
fn reencrypt_command_rewrites_mixed_legacy_and_v3_records() {
let _security_guard = crate::proxy::tests::acquire_security_test_lock();
let _env_guard = lock_env();
let _guard = REENCRYPT_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("reencrypt test lock");
let data_dir = std::env::temp_dir().join(format!(
".gephyr-reencrypt-command-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&data_dir).expect("create temp dir");
let _enc_key_env = ScopedEnvVar::set("ENCRYPTION_KEY", "reencrypt-test-key");
let mut cfg = crate::models::AppConfig::default();
cfg.proxy.proxy_pool.enabled = true;
cfg.proxy.proxy_pool.strategy = ProxySelectionStrategy::Priority;
cfg.proxy.proxy_pool.proxies = vec![ProxyEntry {
id: "proxy-1".to_string(),
name: "proxy-1".to_string(),
url: "http://127.0.0.1:8080".to_string(),
auth: Some(ProxyAuth {
username: "user".to_string(),
password: "password-secret".to_string(),
}),
enabled: true,
priority: 1,
tags: vec![],
max_accounts: None,
health_check_url: None,
last_check_time: None,
is_healthy: true,
latency: None,
}];
let config_path = data_dir.join("config.json");
let config_content = serde_json::to_string_pretty(&cfg).expect("serialize config");
std::fs::write(&config_path, config_content).expect("save config");
let account_v2 = build_test_account("acct-v2", "v2@example.com");
let account_legacy = build_test_account("acct-legacy", "legacy@example.com");
let accounts_dir = data_dir.join("accounts");
std::fs::create_dir_all(&accounts_dir).expect("create accounts dir");
std::fs::write(
accounts_dir.join("acct-v2.json"),
serde_json::to_string_pretty(&account_v2).expect("serialize v2 account"),
)
.expect("save v2 account");
std::fs::write(
accounts_dir.join("acct-legacy.json"),
serde_json::to_string_pretty(&account_legacy).expect("serialize legacy account"),
)
.expect("save legacy account");
let mut config_content = std::fs::read_to_string(&config_path).expect("read config");
config_content = config_content.replacen("v2:", "", 1);
std::fs::write(&config_path, config_content).expect("write legacy config");
let legacy_account_path = accounts_dir.join("acct-legacy.json");
let legacy_account_content =
std::fs::read_to_string(&legacy_account_path).expect("read legacy account file");
std::fs::write(
&legacy_account_path,
legacy_account_content.replace("v2:", ""),
)
.expect("write legacy account file");
let report =
reencrypt_all_secrets_from_data_dir(&data_dir).expect("reencrypt should succeed");
assert!(report.config_rewritten);
assert_eq!(report.accounts_total, 2);
assert_eq!(report.accounts_rewritten, 2);
assert_eq!(report.accounts_failed, 0);
let rewritten_config =
std::fs::read_to_string(&config_path).expect("read rewritten config");
assert!(
rewritten_config.contains("v3:"),
"config secrets should be stored in v3 format"
);
let rewritten_account =
std::fs::read_to_string(&legacy_account_path).expect("read rewritten legacy account");
assert!(
rewritten_account.contains("v3:"),
"account secrets should be stored in v3 format"
);
let loaded_account: Account = serde_json::from_str(
&std::fs::read_to_string(&legacy_account_path).expect("read rewritten account"),
)
.expect("deserialize rewritten account");
assert_eq!(loaded_account.email, "legacy@example.com");
let _ = std::fs::remove_dir_all(&data_dir);
}
}