4186d117af
## Issue Addressed Closes #3049 This PR updates widely but this replace is safe as `File::options()` is equivelent to `OpenOptions::new()`. ref: https://doc.rust-lang.org/stable/src/std/fs.rs.html#378-380
387 lines
11 KiB
Rust
387 lines
11 KiB
Rust
use crate::{
|
|
filesystem::{create, Error as FilesystemError},
|
|
LockedWallet,
|
|
};
|
|
use eth2_wallet::{bip39::Mnemonic, Error as WalletError, Uuid, Wallet, WalletBuilder};
|
|
use lockfile::LockfileError;
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::fs::{create_dir_all, read_dir, File};
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug)]
|
|
pub enum Error {
|
|
DirectoryDoesNotExist(PathBuf),
|
|
WalletError(WalletError),
|
|
FilesystemError(FilesystemError),
|
|
UnableToReadDir(io::Error),
|
|
UnableToReadWallet(io::Error),
|
|
UnableToReadFilename(OsString),
|
|
NameAlreadyTaken(String),
|
|
WalletNameUnknown(String),
|
|
WalletDirExists(PathBuf),
|
|
IoError(io::Error),
|
|
MissingWalletDir(PathBuf),
|
|
UuidMismatch((Uuid, Uuid)),
|
|
LockfileError(LockfileError),
|
|
}
|
|
|
|
impl From<io::Error> for Error {
|
|
fn from(e: io::Error) -> Error {
|
|
Error::IoError(e)
|
|
}
|
|
}
|
|
|
|
impl From<WalletError> for Error {
|
|
fn from(e: WalletError) -> Error {
|
|
Error::WalletError(e)
|
|
}
|
|
}
|
|
|
|
impl From<FilesystemError> for Error {
|
|
fn from(e: FilesystemError) -> Error {
|
|
Error::FilesystemError(e)
|
|
}
|
|
}
|
|
|
|
impl From<LockfileError> for Error {
|
|
fn from(e: LockfileError) -> Error {
|
|
Error::LockfileError(e)
|
|
}
|
|
}
|
|
|
|
/// Defines the type of an EIP-2386 wallet.
|
|
///
|
|
/// Presently only `Hd` wallets are supported.
|
|
pub enum WalletType {
|
|
/// Hierarchical-deterministic.
|
|
Hd,
|
|
}
|
|
|
|
/// Manages a directory containing EIP-2386 wallets.
|
|
///
|
|
/// Each wallet is stored in a directory with the name of the wallet UUID. Inside each directory a
|
|
/// EIP-2386 JSON wallet is also stored using the UUID as the filename.
|
|
///
|
|
/// In each wallet directory an optional `.lock` exists to prevent concurrent reads and writes from
|
|
/// the same wallet.
|
|
///
|
|
/// Example:
|
|
///
|
|
/// ```ignore
|
|
/// wallets
|
|
/// ├── 35c07717-c6f3-45e8-976f-ef5d267e86c9
|
|
/// │ └── 35c07717-c6f3-45e8-976f-ef5d267e86c9
|
|
/// └── 747ad9dc-e1a1-4804-ada4-0dc124e46c49
|
|
/// └── .lock
|
|
/// └── 747ad9dc-e1a1-4804-ada4-0dc124e46c49
|
|
/// ```
|
|
pub struct WalletManager {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl WalletManager {
|
|
/// Open a directory containing multiple wallets.
|
|
///
|
|
/// Pass the `wallets` directory as `dir` (see struct-level example).
|
|
pub fn open<P: AsRef<Path>>(dir: P) -> Result<Self, Error> {
|
|
let dir: PathBuf = dir.as_ref().into();
|
|
|
|
if dir.exists() {
|
|
Ok(Self { dir })
|
|
} else {
|
|
Err(Error::DirectoryDoesNotExist(dir))
|
|
}
|
|
}
|
|
|
|
/// Searches all wallets in `self.dir` and returns the wallet with this name.
|
|
///
|
|
/// ## Errors
|
|
///
|
|
/// - If there is no wallet with this name.
|
|
/// - If there is a file-system or parsing error.
|
|
pub fn wallet_by_name(&self, name: &str) -> Result<LockedWallet, Error> {
|
|
LockedWallet::open(
|
|
self.dir.clone(),
|
|
self.wallets()?
|
|
.get(name)
|
|
.ok_or_else(|| Error::WalletNameUnknown(name.into()))?,
|
|
)
|
|
}
|
|
|
|
/// Creates a new wallet with the given `name` in `self.dir` with the given `mnemonic` as a
|
|
/// seed, encrypted with `password`.
|
|
///
|
|
/// ## Errors
|
|
///
|
|
/// - If a wallet with this name already exists.
|
|
/// - If there is a file-system or parsing error.
|
|
pub fn create_wallet(
|
|
&self,
|
|
name: String,
|
|
_wallet_type: WalletType,
|
|
mnemonic: &Mnemonic,
|
|
password: &[u8],
|
|
) -> Result<LockedWallet, Error> {
|
|
if self.wallets()?.contains_key(&name) {
|
|
return Err(Error::NameAlreadyTaken(name));
|
|
}
|
|
|
|
let wallet = WalletBuilder::from_mnemonic(mnemonic, password, name)?.build()?;
|
|
let uuid = *wallet.uuid();
|
|
|
|
let wallet_dir = self.dir.join(format!("{}", uuid));
|
|
|
|
if wallet_dir.exists() {
|
|
return Err(Error::WalletDirExists(wallet_dir));
|
|
}
|
|
|
|
create_dir_all(&wallet_dir)?;
|
|
|
|
create(&wallet_dir, &wallet)?;
|
|
|
|
drop(wallet);
|
|
|
|
LockedWallet::open(&self.dir, &uuid)
|
|
}
|
|
|
|
/// Iterates all wallets in `self.dir` and returns a mapping of their name to their UUID.
|
|
///
|
|
/// Ignores any items in `self.dir` that:
|
|
///
|
|
/// - Are files.
|
|
/// - Are directories, but their file-name does not parse as a UUID.
|
|
///
|
|
/// This function is fairly strict, it will fail if any directory is found that does not obey
|
|
/// the expected structure (e.g., there is a UUID directory that does not contain a valid JSON
|
|
/// keystore with the same UUID).
|
|
pub fn wallets(&self) -> Result<HashMap<String, Uuid>, Error> {
|
|
let mut wallets = HashMap::new();
|
|
|
|
for f in read_dir(&self.dir).map_err(Error::UnableToReadDir)? {
|
|
let f = f?;
|
|
|
|
// Ignore any non-directory objects in the root wallet dir.
|
|
if f.file_type()?.is_dir() {
|
|
let file_name = f
|
|
.file_name()
|
|
.into_string()
|
|
.map_err(Error::UnableToReadFilename)?;
|
|
|
|
// Ignore any paths that don't parse as a UUID.
|
|
if let Ok(uuid) = Uuid::parse_str(&file_name) {
|
|
let wallet_path = f.path().join(format!("{}", uuid));
|
|
let wallet = File::options()
|
|
.read(true)
|
|
.create(false)
|
|
.open(wallet_path)
|
|
.map_err(Error::UnableToReadWallet)
|
|
.and_then(|f| Wallet::from_json_reader(f).map_err(Error::WalletError))?;
|
|
|
|
if *wallet.uuid() != uuid {
|
|
return Err(Error::UuidMismatch((uuid, *wallet.uuid())));
|
|
}
|
|
|
|
wallets.insert(wallet.name().into(), *wallet.uuid());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(wallets)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
// These tests are very slow in debug, only test in release.
|
|
#[cfg(not(debug_assertions))]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{filesystem::read, locked_wallet::LOCK_FILE};
|
|
use eth2_wallet::bip39::{Language, Mnemonic};
|
|
use tempfile::tempdir;
|
|
|
|
const MNEMONIC: &str =
|
|
"enemy fog enlist laundry nurse hungry discover turkey holiday resemble glad discover";
|
|
const WALLET_PASSWORD: &[u8] = &[43; 43];
|
|
|
|
fn get_mnemonic() -> Mnemonic {
|
|
Mnemonic::from_phrase(MNEMONIC, Language::English).unwrap()
|
|
}
|
|
|
|
fn create_wallet(mgr: &WalletManager, id: usize) -> LockedWallet {
|
|
let wallet = mgr
|
|
.create_wallet(
|
|
format!("{}", id),
|
|
WalletType::Hd,
|
|
&get_mnemonic(),
|
|
WALLET_PASSWORD,
|
|
)
|
|
.expect("should create wallet");
|
|
|
|
assert!(
|
|
wallet_dir_path(&mgr.dir, wallet.wallet().uuid()).exists(),
|
|
"should have created wallet dir"
|
|
);
|
|
assert!(
|
|
json_path(&mgr.dir, wallet.wallet().uuid()).exists(),
|
|
"should have created json file"
|
|
);
|
|
assert!(
|
|
lockfile_path(&mgr.dir, wallet.wallet().uuid()).exists(),
|
|
"should have created lockfile"
|
|
);
|
|
|
|
wallet
|
|
}
|
|
|
|
fn load_wallet_raw<P: AsRef<Path>>(base_dir: P, uuid: &Uuid) -> Wallet {
|
|
read(wallet_dir_path(base_dir, uuid), uuid).expect("should load raw json")
|
|
}
|
|
|
|
fn wallet_dir_path<P: AsRef<Path>>(base_dir: P, uuid: &Uuid) -> PathBuf {
|
|
let s = format!("{}", uuid);
|
|
base_dir.as_ref().join(&s)
|
|
}
|
|
|
|
fn lockfile_path<P: AsRef<Path>>(base_dir: P, uuid: &Uuid) -> PathBuf {
|
|
let s = format!("{}", uuid);
|
|
base_dir.as_ref().join(&s).join(LOCK_FILE)
|
|
}
|
|
|
|
fn json_path<P: AsRef<Path>>(base_dir: P, uuid: &Uuid) -> PathBuf {
|
|
let s = format!("{}", uuid);
|
|
base_dir.as_ref().join(&s).join(&s)
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_names() {
|
|
let dir = tempdir().unwrap();
|
|
let base_dir = dir.path();
|
|
let mgr = WalletManager::open(base_dir).unwrap();
|
|
let name = "cats".to_string();
|
|
|
|
mgr.create_wallet(
|
|
name.clone(),
|
|
WalletType::Hd,
|
|
&get_mnemonic(),
|
|
WALLET_PASSWORD,
|
|
)
|
|
.expect("should create first wallet");
|
|
|
|
match mgr.create_wallet(
|
|
name.clone(),
|
|
WalletType::Hd,
|
|
&get_mnemonic(),
|
|
WALLET_PASSWORD,
|
|
) {
|
|
Err(Error::NameAlreadyTaken(_)) => {}
|
|
_ => panic!("expected name error"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keystore_generation() {
|
|
let dir = tempdir().unwrap();
|
|
let base_dir = dir.path();
|
|
let mgr = WalletManager::open(base_dir).unwrap();
|
|
let name = "cats".to_string();
|
|
|
|
let mut w = mgr
|
|
.create_wallet(
|
|
name.clone(),
|
|
WalletType::Hd,
|
|
&get_mnemonic(),
|
|
WALLET_PASSWORD,
|
|
)
|
|
.expect("should create first wallet");
|
|
|
|
let uuid = w.wallet().uuid().clone();
|
|
|
|
assert_eq!(
|
|
load_wallet_raw(&base_dir, &uuid).nextaccount(),
|
|
0,
|
|
"should start wallet with nextaccount 0"
|
|
);
|
|
|
|
for i in 1..3 {
|
|
w.next_validator(WALLET_PASSWORD, &[50; 32], &[51; 32])
|
|
.expect("should create validator");
|
|
assert_eq!(
|
|
load_wallet_raw(&base_dir, &uuid).nextaccount(),
|
|
i,
|
|
"should update wallet with nextaccount {}",
|
|
i
|
|
);
|
|
}
|
|
|
|
drop(w);
|
|
|
|
// Check that we can open the wallet by name.
|
|
let by_name = mgr.wallet_by_name(&name).unwrap();
|
|
assert_eq!(by_name.wallet().name(), name);
|
|
|
|
drop(by_name);
|
|
|
|
let wallets = mgr.wallets().unwrap().into_iter().collect::<Vec<_>>();
|
|
assert_eq!(wallets, vec![(name, uuid)]);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_wallet_lockfile() {
|
|
let dir = tempdir().unwrap();
|
|
let base_dir = dir.path();
|
|
let mgr = WalletManager::open(base_dir).unwrap();
|
|
|
|
let uuid_a = create_wallet(&mgr, 0).wallet().uuid().clone();
|
|
let uuid_b = create_wallet(&mgr, 1).wallet().uuid().clone();
|
|
|
|
let locked_a = LockedWallet::open(&base_dir, &uuid_a).expect("should open wallet a");
|
|
|
|
assert!(
|
|
lockfile_path(&base_dir, &uuid_a).exists(),
|
|
"lockfile should exist"
|
|
);
|
|
|
|
drop(locked_a);
|
|
|
|
assert!(
|
|
!lockfile_path(&base_dir, &uuid_a).exists(),
|
|
"lockfile have been cleaned up"
|
|
);
|
|
|
|
let locked_a = LockedWallet::open(&base_dir, &uuid_a).expect("should open wallet a");
|
|
let locked_b = LockedWallet::open(&base_dir, &uuid_b).expect("should open wallet b");
|
|
|
|
assert!(
|
|
lockfile_path(&base_dir, &uuid_a).exists(),
|
|
"lockfile a should exist"
|
|
);
|
|
|
|
assert!(
|
|
lockfile_path(&base_dir, &uuid_b).exists(),
|
|
"lockfile b should exist"
|
|
);
|
|
|
|
match LockedWallet::open(&base_dir, &uuid_a) {
|
|
Err(Error::LockfileError(_)) => {}
|
|
_ => panic!("did not get locked error"),
|
|
};
|
|
|
|
drop(locked_a);
|
|
|
|
LockedWallet::open(&base_dir, &uuid_a)
|
|
.expect("should open wallet a after previous instance is dropped");
|
|
|
|
match LockedWallet::open(&base_dir, &uuid_b) {
|
|
Err(Error::LockfileError(_)) => {}
|
|
_ => panic!("did not get locked error"),
|
|
};
|
|
|
|
drop(locked_b);
|
|
|
|
LockedWallet::open(&base_dir, &uuid_b)
|
|
.expect("should open wallet a after previous instance is dropped");
|
|
}
|
|
}
|