use ows_core::{Config, EncryptedWallet};
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::OwsLibError;
#[cfg(unix)]
fn set_dir_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o700);
if let Err(e) = fs::set_permissions(path, perms) {
eprintln!(
"warning: failed to set permissions on {}: {e}",
path.display()
);
}
}
#[cfg(unix)]
fn set_file_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
if let Err(e) = fs::set_permissions(path, perms) {
eprintln!(
"warning: failed to set permissions on {}: {e}",
path.display()
);
}
}
#[cfg(unix)]
pub fn check_vault_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
eprintln!(
"warning: {} has permissions {:04o}, expected 0700",
path.display(),
mode
);
}
}
}
#[cfg(not(unix))]
fn set_dir_permissions(_path: &Path) {}
#[cfg(not(unix))]
fn set_file_permissions(_path: &Path) {}
#[cfg(not(unix))]
pub fn check_vault_permissions(_path: &Path) {}
pub fn resolve_vault_path(vault_path: Option<&Path>) -> PathBuf {
match vault_path {
Some(p) => p.to_path_buf(),
None => Config::default().vault_path,
}
}
pub fn wallets_dir(vault_path: Option<&Path>) -> Result<PathBuf, OwsLibError> {
let lws_dir = resolve_vault_path(vault_path);
let dir = lws_dir.join("wallets");
fs::create_dir_all(&dir)?;
set_dir_permissions(&lws_dir);
set_dir_permissions(&dir);
Ok(dir)
}
pub fn save_encrypted_wallet(
wallet: &EncryptedWallet,
vault_path: Option<&Path>,
) -> Result<(), OwsLibError> {
let dir = wallets_dir(vault_path)?;
let path = dir.join(format!("{}.json", wallet.id));
let json = serde_json::to_string_pretty(wallet)?;
fs::write(&path, json)?;
set_file_permissions(&path);
Ok(())
}
pub fn list_encrypted_wallets(
vault_path: Option<&Path>,
) -> Result<Vec<EncryptedWallet>, OwsLibError> {
let dir = wallets_dir(vault_path)?;
check_vault_permissions(&dir);
let mut wallets = Vec::new();
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(wallets),
Err(e) => return Err(e.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match fs::read_to_string(&path) {
Ok(contents) => match serde_json::from_str::<EncryptedWallet>(&contents) {
Ok(w) => wallets.push(w),
Err(e) => {
eprintln!("warning: skipping {}: {e}", path.display());
}
},
Err(e) => {
eprintln!("warning: skipping {}: {e}", path.display());
}
}
}
wallets.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(wallets)
}
pub fn load_wallet_by_name_or_id(
name_or_id: &str,
vault_path: Option<&Path>,
) -> Result<EncryptedWallet, OwsLibError> {
let wallets = list_encrypted_wallets(vault_path)?;
if let Some(w) = wallets.iter().find(|w| w.id == name_or_id) {
return Ok(w.clone());
}
let matches: Vec<&EncryptedWallet> = wallets.iter().filter(|w| w.name == name_or_id).collect();
match matches.len() {
0 => Err(OwsLibError::WalletNotFound(name_or_id.to_string())),
1 => Ok(matches[0].clone()),
n => Err(OwsLibError::AmbiguousWallet {
name: name_or_id.to_string(),
count: n,
}),
}
}
pub fn delete_wallet_file(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
let dir = wallets_dir(vault_path)?;
let path = dir.join(format!("{id}.json"));
if !path.exists() {
return Err(OwsLibError::WalletNotFound(id.to_string()));
}
fs::remove_file(&path)?;
Ok(())
}
pub fn wallet_name_exists(name: &str, vault_path: Option<&Path>) -> Result<bool, OwsLibError> {
let wallets = list_encrypted_wallets(vault_path)?;
Ok(wallets.iter().any(|w| w.name == name))
}
#[cfg(test)]
mod tests {
use super::*;
use ows_core::{KeyType, WalletAccount};
#[test]
fn test_wallets_dir_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let result = wallets_dir(Some(&vault)).unwrap();
assert!(result.exists());
assert_eq!(result, vault.join("wallets"));
}
#[test]
fn test_save_and_list_wallets() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"test-id".to_string(),
"test-wallet".to_string(),
vec![WalletAccount {
account_id: "eip155:1:0xabc".to_string(),
address: "0xabc".to_string(),
chain_id: "eip155:1".to_string(),
derivation_path: "m/44'/60'/0'/0/0".to_string(),
}],
serde_json::json!({"cipher": "aes-256-gcm"}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let wallets = list_encrypted_wallets(Some(&vault)).unwrap();
assert_eq!(wallets.len(), 1);
assert_eq!(wallets[0].id, "test-id");
}
#[test]
fn test_load_by_name_or_id() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"uuid-123".to_string(),
"my-wallet".to_string(),
vec![WalletAccount {
account_id: "eip155:1:0xabc".to_string(),
address: "0xabc".to_string(),
chain_id: "eip155:1".to_string(),
derivation_path: "m/44'/60'/0'/0/0".to_string(),
}],
serde_json::json!({"cipher": "aes-256-gcm"}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let found = load_wallet_by_name_or_id("uuid-123", Some(&vault)).unwrap();
assert_eq!(found.name, "my-wallet");
let found = load_wallet_by_name_or_id("my-wallet", Some(&vault)).unwrap();
assert_eq!(found.id, "uuid-123");
let err = load_wallet_by_name_or_id("nonexistent", Some(&vault));
assert!(err.is_err());
}
#[test]
fn test_delete_wallet_file() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"del-id".to_string(),
"del-wallet".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 1);
delete_wallet_file("del-id", Some(&vault)).unwrap();
assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 0);
}
#[test]
fn test_wallet_name_exists() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"id-1".to_string(),
"existing-name".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
assert!(wallet_name_exists("existing-name", Some(&vault)).unwrap());
assert!(!wallet_name_exists("other-name", Some(&vault)).unwrap());
}
#[test]
fn char_save_and_load_by_id() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"char-id-123".to_string(),
"char-wallet".to_string(),
vec![WalletAccount {
account_id: "eip155:1:0xabc".to_string(),
address: "0xabc".to_string(),
chain_id: "eip155:1".to_string(),
derivation_path: "m/44'/60'/0'/0/0".to_string(),
}],
serde_json::json!({"cipher": "aes-256-gcm"}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let loaded = load_wallet_by_name_or_id("char-id-123", Some(&vault)).unwrap();
assert_eq!(loaded.id, wallet.id);
assert_eq!(loaded.name, wallet.name);
assert_eq!(loaded.accounts.len(), 1);
assert_eq!(loaded.accounts[0].address, "0xabc");
assert_eq!(loaded.key_type, KeyType::Mnemonic);
}
#[test]
fn char_save_and_load_by_name() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"char-uuid-456".to_string(),
"my-char-wallet".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let loaded = load_wallet_by_name_or_id("my-char-wallet", Some(&vault)).unwrap();
assert_eq!(loaded.id, "char-uuid-456");
}
#[test]
fn char_path_traversal_in_save_rejected() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"../../../etc/passwd".to_string(),
"evil-wallet".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
let result = save_encrypted_wallet(&wallet, Some(&vault));
if result.is_ok() {
let wallets_dir_path = vault.join("wallets");
let _escaped_path = vault.join("wallets").join("../../../etc/passwd.json");
let canonical_wallets = wallets_dir_path.canonicalize().unwrap();
let entries: Vec<_> = std::fs::read_dir(&wallets_dir_path)
.unwrap()
.filter_map(|e| e.ok())
.collect();
for entry in &entries {
let path = entry.path().canonicalize().unwrap();
assert!(
path.starts_with(&canonical_wallets),
"wallet file {:?} escaped the vault directory",
path
);
}
}
}
#[test]
fn char_path_traversal_in_delete_rejected() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"legit-id".to_string(),
"legit".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let result = delete_wallet_file("../../../etc/passwd", Some(&vault));
assert!(result.is_err());
assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 1);
}
#[test]
fn char_list_returns_newest_first() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let w1 = EncryptedWallet::new(
"w1-id".to_string(),
"wallet-1".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&w1, Some(&vault)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let w2 = EncryptedWallet::new(
"w2-id".to_string(),
"wallet-2".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&w2, Some(&vault)).unwrap();
let wallets = list_encrypted_wallets(Some(&vault)).unwrap();
assert_eq!(wallets.len(), 2);
assert_eq!(wallets[0].id, "w2-id");
assert_eq!(wallets[1].id, "w1-id");
}
#[test]
fn char_duplicate_wallet_name_detected() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let w1 = EncryptedWallet::new(
"id-a".to_string(),
"same-name".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&w1, Some(&vault)).unwrap();
assert!(wallet_name_exists("same-name", Some(&vault)).unwrap());
}
#[test]
fn char_wallet_not_found_returns_error() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let result = load_wallet_by_name_or_id("nonexistent", Some(&vault));
assert!(result.is_err());
match result.unwrap_err() {
OwsLibError::WalletNotFound(name) => assert_eq!(name, "nonexistent"),
other => panic!("expected WalletNotFound, got: {other}"),
}
}
#[test]
fn char_delete_nonexistent_wallet_returns_error() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let result = delete_wallet_file("no-such-id", Some(&vault));
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn char_wallet_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_path_buf();
let wallet = EncryptedWallet::new(
"perm-id".to_string(),
"perm-wallet".to_string(),
vec![],
serde_json::json!({}),
KeyType::Mnemonic,
);
save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
let file_path = vault.join("wallets/perm-id.json");
let meta = std::fs::metadata(&file_path).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"wallet file should have 0600 permissions, got {:04o}",
mode
);
let wallets_dir_path = vault.join("wallets");
let dir_meta = std::fs::metadata(&wallets_dir_path).unwrap();
let dir_mode = dir_meta.permissions().mode() & 0o777;
assert_eq!(
dir_mode, 0o700,
"wallets directory should have 0700 permissions, got {:04o}",
dir_mode
);
}
}