Skip to main content

ows_lib/
vault.rs

1use ows_core::{Config, EncryptedWallet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::OwsLibError;
6
7/// Set directory permissions to 0o700 (owner-only).
8#[cfg(unix)]
9fn set_dir_permissions(path: &Path) {
10    use std::os::unix::fs::PermissionsExt;
11    let perms = fs::Permissions::from_mode(0o700);
12    if let Err(e) = fs::set_permissions(path, perms) {
13        eprintln!(
14            "warning: failed to set permissions on {}: {e}",
15            path.display()
16        );
17    }
18}
19
20/// Set file permissions to 0o600 (owner read/write only).
21#[cfg(unix)]
22fn set_file_permissions(path: &Path) {
23    use std::os::unix::fs::PermissionsExt;
24    let perms = fs::Permissions::from_mode(0o600);
25    if let Err(e) = fs::set_permissions(path, perms) {
26        eprintln!(
27            "warning: failed to set permissions on {}: {e}",
28            path.display()
29        );
30    }
31}
32
33/// Warn if a directory has permissions more open than 0o700.
34#[cfg(unix)]
35pub fn check_vault_permissions(path: &Path) {
36    use std::os::unix::fs::PermissionsExt;
37    if let Ok(meta) = fs::metadata(path) {
38        let mode = meta.permissions().mode() & 0o777;
39        if mode != 0o700 {
40            eprintln!(
41                "warning: {} has permissions {:04o}, expected 0700",
42                path.display(),
43                mode
44            );
45        }
46    }
47}
48
49#[cfg(not(unix))]
50fn set_dir_permissions(_path: &Path) {}
51
52#[cfg(not(unix))]
53fn set_file_permissions(_path: &Path) {}
54
55#[cfg(not(unix))]
56pub fn check_vault_permissions(_path: &Path) {}
57
58/// Resolve the vault path: use explicit path if provided, otherwise default (~/.ows).
59pub fn resolve_vault_path(vault_path: Option<&Path>) -> PathBuf {
60    match vault_path {
61        Some(p) => p.to_path_buf(),
62        None => Config::default().vault_path,
63    }
64}
65
66/// Returns the wallets directory, creating it with strict permissions if necessary.
67pub fn wallets_dir(vault_path: Option<&Path>) -> Result<PathBuf, OwsLibError> {
68    let lws_dir = resolve_vault_path(vault_path);
69    let dir = lws_dir.join("wallets");
70    fs::create_dir_all(&dir)?;
71    set_dir_permissions(&lws_dir);
72    set_dir_permissions(&dir);
73    Ok(dir)
74}
75
76/// Save an encrypted wallet file with strict permissions.
77pub fn save_encrypted_wallet(
78    wallet: &EncryptedWallet,
79    vault_path: Option<&Path>,
80) -> Result<(), OwsLibError> {
81    let dir = wallets_dir(vault_path)?;
82    let path = dir.join(format!("{}.json", wallet.id));
83    let json = serde_json::to_string_pretty(wallet)?;
84    fs::write(&path, json)?;
85    set_file_permissions(&path);
86    Ok(())
87}
88
89/// Load all encrypted wallets from the vault.
90/// Checks directory permissions and warns if insecure.
91/// Returns wallets sorted by created_at descending (newest first).
92pub fn list_encrypted_wallets(
93    vault_path: Option<&Path>,
94) -> Result<Vec<EncryptedWallet>, OwsLibError> {
95    let dir = wallets_dir(vault_path)?;
96    check_vault_permissions(&dir);
97
98    let mut wallets = Vec::new();
99
100    let entries = match fs::read_dir(&dir) {
101        Ok(entries) => entries,
102        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(wallets),
103        Err(e) => return Err(e.into()),
104    };
105
106    for entry in entries {
107        let entry = entry?;
108        let path = entry.path();
109        if path.extension().and_then(|e| e.to_str()) != Some("json") {
110            continue;
111        }
112        match fs::read_to_string(&path) {
113            Ok(contents) => match serde_json::from_str::<EncryptedWallet>(&contents) {
114                Ok(w) => wallets.push(w),
115                Err(e) => {
116                    eprintln!("warning: skipping {}: {e}", path.display());
117                }
118            },
119            Err(e) => {
120                eprintln!("warning: skipping {}: {e}", path.display());
121            }
122        }
123    }
124
125    wallets.sort_by(|a, b| b.created_at.cmp(&a.created_at));
126    Ok(wallets)
127}
128
129/// Look up a wallet by exact ID first, then by name (case-sensitive).
130/// Returns an error if no wallet matches or if the name is ambiguous.
131pub fn load_wallet_by_name_or_id(
132    name_or_id: &str,
133    vault_path: Option<&Path>,
134) -> Result<EncryptedWallet, OwsLibError> {
135    let wallets = list_encrypted_wallets(vault_path)?;
136
137    // Try exact ID match first
138    if let Some(w) = wallets.iter().find(|w| w.id == name_or_id) {
139        return Ok(w.clone());
140    }
141
142    // Try name match (case-sensitive)
143    let matches: Vec<&EncryptedWallet> = wallets.iter().filter(|w| w.name == name_or_id).collect();
144    match matches.len() {
145        0 => Err(OwsLibError::WalletNotFound(name_or_id.to_string())),
146        1 => Ok(matches[0].clone()),
147        n => Err(OwsLibError::AmbiguousWallet {
148            name: name_or_id.to_string(),
149            count: n,
150        }),
151    }
152}
153
154/// Delete a wallet file from the vault by ID.
155pub fn delete_wallet_file(id: &str, vault_path: Option<&Path>) -> Result<(), OwsLibError> {
156    let dir = wallets_dir(vault_path)?;
157    let path = dir.join(format!("{id}.json"));
158    if !path.exists() {
159        return Err(OwsLibError::WalletNotFound(id.to_string()));
160    }
161    fs::remove_file(&path)?;
162    Ok(())
163}
164
165/// Check whether a wallet with the given name already exists in the vault.
166pub fn wallet_name_exists(name: &str, vault_path: Option<&Path>) -> Result<bool, OwsLibError> {
167    let wallets = list_encrypted_wallets(vault_path)?;
168    Ok(wallets.iter().any(|w| w.name == name))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use ows_core::{KeyType, WalletAccount};
175
176    #[test]
177    fn test_wallets_dir_creates_directory() {
178        let dir = tempfile::tempdir().unwrap();
179        let vault = dir.path().to_path_buf();
180        let result = wallets_dir(Some(&vault)).unwrap();
181        assert!(result.exists());
182        assert_eq!(result, vault.join("wallets"));
183    }
184
185    #[test]
186    fn test_save_and_list_wallets() {
187        let dir = tempfile::tempdir().unwrap();
188        let vault = dir.path().to_path_buf();
189
190        let wallet = EncryptedWallet::new(
191            "test-id".to_string(),
192            "test-wallet".to_string(),
193            vec![WalletAccount {
194                account_id: "eip155:1:0xabc".to_string(),
195                address: "0xabc".to_string(),
196                chain_id: "eip155:1".to_string(),
197                derivation_path: "m/44'/60'/0'/0/0".to_string(),
198            }],
199            serde_json::json!({"cipher": "aes-256-gcm"}),
200            KeyType::Mnemonic,
201        );
202
203        save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
204        let wallets = list_encrypted_wallets(Some(&vault)).unwrap();
205        assert_eq!(wallets.len(), 1);
206        assert_eq!(wallets[0].id, "test-id");
207    }
208
209    #[test]
210    fn test_load_by_name_or_id() {
211        let dir = tempfile::tempdir().unwrap();
212        let vault = dir.path().to_path_buf();
213
214        let wallet = EncryptedWallet::new(
215            "uuid-123".to_string(),
216            "my-wallet".to_string(),
217            vec![WalletAccount {
218                account_id: "eip155:1:0xabc".to_string(),
219                address: "0xabc".to_string(),
220                chain_id: "eip155:1".to_string(),
221                derivation_path: "m/44'/60'/0'/0/0".to_string(),
222            }],
223            serde_json::json!({"cipher": "aes-256-gcm"}),
224            KeyType::Mnemonic,
225        );
226
227        save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
228
229        // Find by ID
230        let found = load_wallet_by_name_or_id("uuid-123", Some(&vault)).unwrap();
231        assert_eq!(found.name, "my-wallet");
232
233        // Find by name
234        let found = load_wallet_by_name_or_id("my-wallet", Some(&vault)).unwrap();
235        assert_eq!(found.id, "uuid-123");
236
237        // Not found
238        let err = load_wallet_by_name_or_id("nonexistent", Some(&vault));
239        assert!(err.is_err());
240    }
241
242    #[test]
243    fn test_delete_wallet_file() {
244        let dir = tempfile::tempdir().unwrap();
245        let vault = dir.path().to_path_buf();
246
247        let wallet = EncryptedWallet::new(
248            "del-id".to_string(),
249            "del-wallet".to_string(),
250            vec![],
251            serde_json::json!({}),
252            KeyType::Mnemonic,
253        );
254
255        save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
256        assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 1);
257
258        delete_wallet_file("del-id", Some(&vault)).unwrap();
259        assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 0);
260    }
261
262    #[test]
263    fn test_wallet_name_exists() {
264        let dir = tempfile::tempdir().unwrap();
265        let vault = dir.path().to_path_buf();
266
267        let wallet = EncryptedWallet::new(
268            "id-1".to_string(),
269            "existing-name".to_string(),
270            vec![],
271            serde_json::json!({}),
272            KeyType::Mnemonic,
273        );
274
275        save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
276        assert!(wallet_name_exists("existing-name", Some(&vault)).unwrap());
277        assert!(!wallet_name_exists("other-name", Some(&vault)).unwrap());
278    }
279}