1use ows_core::{Config, EncryptedWallet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::OwsLibError;
6
7#[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#[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#[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
58pub 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
66pub 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
76pub 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
89pub 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
129pub 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 if let Some(w) = wallets.iter().find(|w| w.id == name_or_id) {
139 return Ok(w.clone());
140 }
141
142 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
154pub 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
165pub 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 let found = load_wallet_by_name_or_id("uuid-123", Some(&vault)).unwrap();
231 assert_eq!(found.name, "my-wallet");
232
233 let found = load_wallet_by_name_or_id("my-wallet", Some(&vault)).unwrap();
235 assert_eq!(found.id, "uuid-123");
236
237 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
280 #[test]
283 fn char_save_and_load_by_id() {
284 let dir = tempfile::tempdir().unwrap();
285 let vault = dir.path().to_path_buf();
286
287 let wallet = EncryptedWallet::new(
288 "char-id-123".to_string(),
289 "char-wallet".to_string(),
290 vec![WalletAccount {
291 account_id: "eip155:1:0xabc".to_string(),
292 address: "0xabc".to_string(),
293 chain_id: "eip155:1".to_string(),
294 derivation_path: "m/44'/60'/0'/0/0".to_string(),
295 }],
296 serde_json::json!({"cipher": "aes-256-gcm"}),
297 KeyType::Mnemonic,
298 );
299
300 save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
301
302 let loaded = load_wallet_by_name_or_id("char-id-123", Some(&vault)).unwrap();
303 assert_eq!(loaded.id, wallet.id);
304 assert_eq!(loaded.name, wallet.name);
305 assert_eq!(loaded.accounts.len(), 1);
306 assert_eq!(loaded.accounts[0].address, "0xabc");
307 assert_eq!(loaded.key_type, KeyType::Mnemonic);
308 }
309
310 #[test]
311 fn char_save_and_load_by_name() {
312 let dir = tempfile::tempdir().unwrap();
313 let vault = dir.path().to_path_buf();
314
315 let wallet = EncryptedWallet::new(
316 "char-uuid-456".to_string(),
317 "my-char-wallet".to_string(),
318 vec![],
319 serde_json::json!({}),
320 KeyType::Mnemonic,
321 );
322
323 save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
324
325 let loaded = load_wallet_by_name_or_id("my-char-wallet", Some(&vault)).unwrap();
326 assert_eq!(loaded.id, "char-uuid-456");
327 }
328
329 #[test]
330 fn char_path_traversal_in_save_rejected() {
331 let dir = tempfile::tempdir().unwrap();
333 let vault = dir.path().to_path_buf();
334
335 let wallet = EncryptedWallet::new(
336 "../../../etc/passwd".to_string(),
337 "evil-wallet".to_string(),
338 vec![],
339 serde_json::json!({}),
340 KeyType::Mnemonic,
341 );
342
343 let result = save_encrypted_wallet(&wallet, Some(&vault));
346 if result.is_ok() {
347 let wallets_dir_path = vault.join("wallets");
349 let _escaped_path = vault.join("wallets").join("../../../etc/passwd.json");
350 let canonical_wallets = wallets_dir_path.canonicalize().unwrap();
351
352 let entries: Vec<_> = std::fs::read_dir(&wallets_dir_path)
354 .unwrap()
355 .filter_map(|e| e.ok())
356 .collect();
357
358 for entry in &entries {
360 let path = entry.path().canonicalize().unwrap();
361 assert!(
362 path.starts_with(&canonical_wallets),
363 "wallet file {:?} escaped the vault directory",
364 path
365 );
366 }
367 }
368 }
370
371 #[test]
372 fn char_path_traversal_in_delete_rejected() {
373 let dir = tempfile::tempdir().unwrap();
374 let vault = dir.path().to_path_buf();
375
376 let wallet = EncryptedWallet::new(
378 "legit-id".to_string(),
379 "legit".to_string(),
380 vec![],
381 serde_json::json!({}),
382 KeyType::Mnemonic,
383 );
384 save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
385
386 let result = delete_wallet_file("../../../etc/passwd", Some(&vault));
388 assert!(result.is_err());
390
391 assert_eq!(list_encrypted_wallets(Some(&vault)).unwrap().len(), 1);
393 }
394
395 #[test]
396 fn char_list_returns_newest_first() {
397 let dir = tempfile::tempdir().unwrap();
398 let vault = dir.path().to_path_buf();
399
400 let w1 = EncryptedWallet::new(
401 "w1-id".to_string(),
402 "wallet-1".to_string(),
403 vec![],
404 serde_json::json!({}),
405 KeyType::Mnemonic,
406 );
407 save_encrypted_wallet(&w1, Some(&vault)).unwrap();
408
409 std::thread::sleep(std::time::Duration::from_millis(10));
411
412 let w2 = EncryptedWallet::new(
413 "w2-id".to_string(),
414 "wallet-2".to_string(),
415 vec![],
416 serde_json::json!({}),
417 KeyType::Mnemonic,
418 );
419 save_encrypted_wallet(&w2, Some(&vault)).unwrap();
420
421 let wallets = list_encrypted_wallets(Some(&vault)).unwrap();
422 assert_eq!(wallets.len(), 2);
423 assert_eq!(wallets[0].id, "w2-id");
425 assert_eq!(wallets[1].id, "w1-id");
426 }
427
428 #[test]
429 fn char_duplicate_wallet_name_detected() {
430 let dir = tempfile::tempdir().unwrap();
431 let vault = dir.path().to_path_buf();
432
433 let w1 = EncryptedWallet::new(
434 "id-a".to_string(),
435 "same-name".to_string(),
436 vec![],
437 serde_json::json!({}),
438 KeyType::Mnemonic,
439 );
440 save_encrypted_wallet(&w1, Some(&vault)).unwrap();
441
442 assert!(wallet_name_exists("same-name", Some(&vault)).unwrap());
443 }
444
445 #[test]
446 fn char_wallet_not_found_returns_error() {
447 let dir = tempfile::tempdir().unwrap();
448 let vault = dir.path().to_path_buf();
449
450 let result = load_wallet_by_name_or_id("nonexistent", Some(&vault));
451 assert!(result.is_err());
452 match result.unwrap_err() {
453 OwsLibError::WalletNotFound(name) => assert_eq!(name, "nonexistent"),
454 other => panic!("expected WalletNotFound, got: {other}"),
455 }
456 }
457
458 #[test]
459 fn char_delete_nonexistent_wallet_returns_error() {
460 let dir = tempfile::tempdir().unwrap();
461 let vault = dir.path().to_path_buf();
462
463 let result = delete_wallet_file("no-such-id", Some(&vault));
464 assert!(result.is_err());
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn char_wallet_file_permissions() {
470 use std::os::unix::fs::PermissionsExt;
471
472 let dir = tempfile::tempdir().unwrap();
473 let vault = dir.path().to_path_buf();
474
475 let wallet = EncryptedWallet::new(
476 "perm-id".to_string(),
477 "perm-wallet".to_string(),
478 vec![],
479 serde_json::json!({}),
480 KeyType::Mnemonic,
481 );
482 save_encrypted_wallet(&wallet, Some(&vault)).unwrap();
483
484 let file_path = vault.join("wallets/perm-id.json");
486 let meta = std::fs::metadata(&file_path).unwrap();
487 let mode = meta.permissions().mode() & 0o777;
488 assert_eq!(
489 mode, 0o600,
490 "wallet file should have 0600 permissions, got {:04o}",
491 mode
492 );
493
494 let wallets_dir_path = vault.join("wallets");
496 let dir_meta = std::fs::metadata(&wallets_dir_path).unwrap();
497 let dir_mode = dir_meta.permissions().mode() & 0o777;
498 assert_eq!(
499 dir_mode, 0o700,
500 "wallets directory should have 0700 permissions, got {:04o}",
501 dir_mode
502 );
503 }
504}