From 4f86e9604f980e612148a306aef05a6bdcae445f Mon Sep 17 00:00:00 2001 From: kernelkind Date: Tue, 10 Sep 2024 17:56:57 -0400 Subject: [PATCH] file storage Signed-off-by: kernelkind --- Cargo.lock | 75 ++++- Cargo.toml | 3 + src/account_manager.rs | 29 +- src/app.rs | 37 ++- src/args.rs | 5 + src/key_storage.rs | 90 ------ src/lib.rs | 6 +- src/linux_key_storage.rs | 210 -------------- src/storage/file_key_storage.rs | 176 ++++++++++++ src/storage/file_storage.rs | 259 ++++++++++++++++++ src/storage/key_storage_impl.rs | 112 ++++++++ src/storage/mod.rs | 14 + .../security_framework_key_storage.rs} | 69 ++--- 13 files changed, 727 insertions(+), 358 deletions(-) delete mode 100644 src/key_storage.rs delete mode 100644 src/linux_key_storage.rs create mode 100644 src/storage/file_key_storage.rs create mode 100644 src/storage/file_storage.rs create mode 100644 src/storage/key_storage_impl.rs create mode 100644 src/storage/mod.rs rename src/{macos_key_storage.rs => storage/security_framework_key_storage.rs} (71%) diff --git a/Cargo.lock b/Cargo.lock index 8ca5953..08d3eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,6 +991,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1358,6 +1379,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + [[package]] name = "fdeflate" version = "0.3.4" @@ -2183,6 +2210,16 @@ dependencies = [ "redox_syscall 0.4.1", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -2503,6 +2540,7 @@ dependencies = [ "base32", "bitflags 2.6.0", "console_error_panic_hook", + "dirs", "eframe", "egui", "egui_extras", @@ -2527,6 +2565,7 @@ dependencies = [ "serde_json", "strum", "strum_macros", + "tempfile", "tokio", "tracing", "tracing-subscriber", @@ -2816,13 +2855,19 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52f0d54bde9774d3a51dcf281a5def240c71996bc6ca05d2c847ec8b2b216166" dependencies = [ - "libredox", + "libredox 0.0.2", ] [[package]] @@ -3333,6 +3378,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox 0.1.3", + "thiserror", +] + [[package]] name = "regex" version = "1.10.6" @@ -3500,9 +3556,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", @@ -4076,6 +4132,19 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 17e99ef..8213a36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,10 @@ strum_macros = "0.26" bitflags = "2.5.0" uuid = { version = "1.10.0", features = ["v4"] } indexmap = "2.6.0" +dirs = "5.0.1" +[dev-dependencies] +tempfile = "3.13.0" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.11.0" diff --git a/src/account_manager.rs b/src/account_manager.rs index 17d8706..6ae2521 100644 --- a/src/account_manager.rs +++ b/src/account_manager.rs @@ -6,15 +6,15 @@ use nostrdb::Ndb; use crate::{ column::Columns, imgcache::ImageCache, - key_storage::{KeyStorage, KeyStorageResponse, KeyStorageType}, login_manager::LoginState, route::{Route, Router}, + storage::{KeyStorageResponse, KeyStorageType}, ui::{ account_login_view::{AccountLoginResponse, AccountLoginView}, account_management::{AccountsView, AccountsViewResponse}, }, }; -use tracing::info; +use tracing::{error, info}; pub use crate::user_account::UserAccount; @@ -96,13 +96,14 @@ pub fn process_accounts_view_response( } impl AccountManager { - pub fn new(currently_selected_account: Option, key_store: KeyStorageType) -> Self { + pub fn new(key_store: KeyStorageType) -> Self { let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() { res.unwrap_or_default() } else { Vec::new() }; + let currently_selected_account = get_selected_index(&accounts, &key_store); AccountManager { currently_selected_account, accounts, @@ -188,16 +189,31 @@ impl AccountManager { } pub fn select_account(&mut self, index: usize) { - if self.accounts.get(index).is_some() { - self.currently_selected_account = Some(index) + if let Some(account) = self.accounts.get(index) { + self.currently_selected_account = Some(index); + self.key_store.select_key(Some(account.pubkey)); } } pub fn clear_selected_account(&mut self) { - self.currently_selected_account = None + self.currently_selected_account = None; + self.key_store.select_key(None); } } +fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option { + match keystore.get_selected_key() { + KeyStorageResponse::ReceivedResult(Ok(Some(pubkey))) => { + return accounts.iter().position(|account| account.pubkey == pubkey); + } + + KeyStorageResponse::ReceivedResult(Err(e)) => error!("Error getting selected key: {}", e), + _ => (), + }; + + None +} + pub fn process_login_view_response(manager: &mut AccountManager, response: AccountLoginResponse) { match response { AccountLoginResponse::CreateNew => { @@ -207,4 +223,5 @@ pub fn process_login_view_response(manager: &mut AccountManager, response: Accou manager.add_account(keypair); } } + manager.select_account(manager.num_accounts() - 1); } diff --git a/src/app.rs b/src/app.rs index e04b8ad..ce7382a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,19 +9,19 @@ use crate::{ filter::{self, FilterState}, frame_history::FrameHistory, imgcache::ImageCache, - key_storage::KeyStorageType, nav, note::NoteRef, notecache::{CachedNote, NoteCache}, notes_holder::NotesHolderStorage, profile::Profile, + storage::{Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, thread::Thread, timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, - Result, + DataPaths, Result, }; use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool}; @@ -664,23 +664,32 @@ impl Damus { let mut config = Config::new(); config.set_ingester_threads(4); - let mut accounts = AccountManager::new( - // TODO: should pull this from settings - None, - // TODO: use correct KeyStorage mechanism for current OS arch - KeyStorageType::None, - ); + let keystore = if parsed_args.use_keystore { + if let Ok(keys_path) = DataPaths::Keys.get_path() { + if let Ok(selected_key_path) = DataPaths::SelectedKey.get_path() { + KeyStorageType::FileSystem(FileKeyStorage::new( + Directory::new(keys_path), + Directory::new(selected_key_path), + )) + } else { + error!("Could not find path for selected key"); + KeyStorageType::None + } + } else { + error!("Could not find data path for keys"); + KeyStorageType::None + } + } else { + KeyStorageType::None + }; + + let mut accounts = AccountManager::new(keystore); for key in parsed_args.keys { info!("adding account: {}", key.pubkey); accounts.add_account(key); } - // TODO: pull currently selected account from settings - if accounts.num_accounts() > 0 { - accounts.select_account(0); - } - // setup relays if we have them let pool = if parsed_args.relays.is_empty() { let mut pool = RelayPool::new(); @@ -817,7 +826,7 @@ impl Damus { columns, textmode: false, ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), - accounts: AccountManager::new(None, KeyStorageType::None), + accounts: AccountManager::new(KeyStorageType::None), frame_history: FrameHistory::default(), view_state: ViewState::default(), } diff --git a/src/args.rs b/src/args.rs index 3d7d8ca..d6427d8 100644 --- a/src/args.rs +++ b/src/args.rs @@ -13,6 +13,7 @@ pub struct Args { pub light: bool, pub debug: bool, pub textmode: bool, + pub use_keystore: bool, pub dbpath: Option, pub datapath: Option, } @@ -28,6 +29,7 @@ impl Args { since_optimize: true, debug: false, textmode: false, + use_keystore: true, dbpath: None, datapath: None, }; @@ -210,6 +212,9 @@ impl Args { } else { error!("failed to parse filter in '{}'", filter_file); } + } else if arg == "--no-keystore" { + i += 1; + res.use_keystore = false; } i += 1; diff --git a/src/key_storage.rs b/src/key_storage.rs deleted file mode 100644 index 425020f..0000000 --- a/src/key_storage.rs +++ /dev/null @@ -1,90 +0,0 @@ -use enostr::Keypair; - -#[cfg(target_os = "linux")] -use crate::linux_key_storage::LinuxKeyStorage; -#[cfg(target_os = "macos")] -use crate::macos_key_storage::MacOSKeyStorage; - -#[cfg(target_os = "macos")] -pub const SERVICE_NAME: &str = "Notedeck"; - -#[derive(Debug, PartialEq)] -pub enum KeyStorageType { - None, - #[cfg(target_os = "macos")] - MacOS, - #[cfg(target_os = "linux")] - Linux, - // TODO: - // Windows, - // Android, -} - -#[allow(dead_code)] -#[derive(Debug, PartialEq)] -pub enum KeyStorageResponse { - Waiting, - ReceivedResult(Result), -} - -pub trait KeyStorage { - fn get_keys(&self) -> KeyStorageResponse>; - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()>; - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()>; -} - -impl KeyStorage for KeyStorageType { - fn get_keys(&self) -> KeyStorageResponse> { - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).get_keys(), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().get_keys(), - } - } - - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - let _ = key; - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).add_key(key), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().add_key(key), - } - } - - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - let _ = key; - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).remove_key(key), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().remove_key(key), - } - } -} - -#[allow(dead_code)] -#[derive(Debug, PartialEq)] -pub enum KeyStorageError { - Retrieval(String), - Addition(String), - Removal(String), - OSError(String), -} - -impl std::fmt::Display for KeyStorageError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e), - Self::Addition(key) => write!(f, "Failed to add key: {:?}", key), - Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key), - Self::OSError(e) => write!(f, "OS had an error: {:?}", e), - } - } -} - -impl std::error::Error for KeyStorageError {} diff --git a/src/lib.rs b/src/lib.rs index a44abc6..6d35e2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,9 +18,7 @@ mod frame_history; mod images; mod imgcache; mod key_parsing; -mod key_storage; pub mod login_manager; -mod macos_key_storage; mod multi_subscriber; mod nav; mod note; @@ -45,11 +43,13 @@ mod view_state; #[cfg(test)] #[macro_use] mod test_utils; -mod linux_key_storage; + +mod storage; pub use app::Damus; pub use error::Error; pub use profile::DisplayName; +pub use storage::DataPaths; #[cfg(target_os = "android")] use winit::platform::android::EventLoopBuilderExtAndroid; diff --git a/src/linux_key_storage.rs b/src/linux_key_storage.rs deleted file mode 100644 index 4faa042..0000000 --- a/src/linux_key_storage.rs +++ /dev/null @@ -1,210 +0,0 @@ -#![cfg(target_os = "linux")] - -use enostr::{Keypair, SerializableKeypair}; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::{env, fs::File}; - -use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse}; -use tracing::debug; - -enum LinuxKeyStorageType { - BasicFileStorage, - // TODO(kernelkind): could use the secret service api, and maybe even allow password manager integration via a settings menu -} - -pub struct LinuxKeyStorage {} - -// TODO(kernelkind): read from settings instead of hard-coding -static USE_MECHANISM: LinuxKeyStorageType = LinuxKeyStorageType::BasicFileStorage; - -impl LinuxKeyStorage { - pub fn new() -> Self { - Self {} - } -} - -impl KeyStorage for LinuxKeyStorage { - fn get_keys(&self) -> KeyStorageResponse> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().get_keys(), - } - } - - fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().add_key(key), - } - } - - fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().remove_key(key), - } - } -} - -struct BasicFileStorage { - credential_dir_name: String, -} - -impl BasicFileStorage { - pub fn new() -> Self { - Self { - credential_dir_name: ".credentials".to_string(), - } - } - - fn mock() -> Self { - Self { - credential_dir_name: ".credentials_test".to_string(), - } - } - - fn get_cred_dirpath(&self) -> Result { - let home_dir = env::var("HOME") - .map_err(|_| KeyStorageError::OSError("HOME env variable not set".to_string()))?; - let home_path = std::path::PathBuf::from(home_dir); - let project_path_str = "notedeck"; - - let config_path = { - if let Some(xdg_config_str) = env::var_os("XDG_CONFIG_HOME") { - let xdg_path = PathBuf::from(xdg_config_str); - let xdg_path_config = if xdg_path.is_absolute() { - xdg_path - } else { - home_path.join(".config") - }; - xdg_path_config.join(project_path_str) - } else { - home_path.join(format!(".{}", project_path_str)) - } - } - .join(self.credential_dir_name.clone()); - - std::fs::create_dir_all(&config_path).map_err(|_| { - KeyStorageError::OSError(format!( - "could not create config path: {}", - config_path.display() - )) - })?; - - Ok(config_path) - } - - fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { - let mut file_path = self.get_cred_dirpath()?; - file_path.push(format!("{}", &key.pubkey)); - - let mut file = File::create(file_path) - .map_err(|_| KeyStorageError::Addition("could not create or open file".to_string()))?; - - let json_str = serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7)) - .map_err(|e| KeyStorageError::Addition(e.to_string()))?; - file.write_all(json_str.as_bytes()).map_err(|_| { - KeyStorageError::Addition("could not write keypair to file".to_string()) - })?; - - Ok(()) - } - - fn get_keys_internal(&self) -> Result, KeyStorageError> { - let file_path = self.get_cred_dirpath()?; - let mut keys: Vec = Vec::new(); - - if !file_path.is_dir() { - return Err(KeyStorageError::Retrieval( - "path is not a directory".to_string(), - )); - } - - let dir = fs::read_dir(file_path).map_err(|_| { - KeyStorageError::Retrieval("problem accessing credentials directory".to_string()) - })?; - - for entry in dir { - let entry = entry.map_err(|_| { - KeyStorageError::Retrieval("problem accessing crediential file".to_string()) - })?; - - let path = entry.path(); - - if path.is_file() { - if let Some(path_str) = path.to_str() { - debug!("key path {}", path_str); - let json_string = fs::read_to_string(path_str).map_err(|e| { - KeyStorageError::OSError(format!("File reading problem: {}", e)) - })?; - let key: SerializableKeypair = - serde_json::from_str(&json_string).map_err(|e| { - KeyStorageError::OSError(format!( - "Deserialization problem: {}", - (e.to_string().as_str()) - )) - })?; - keys.push(key.to_keypair("")) - } - } - } - - Ok(keys) - } - - fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { - let path = self.get_cred_dirpath()?; - - let filepath = path.join(key.pubkey.to_string()); - - if filepath.exists() && filepath.is_file() { - fs::remove_file(&filepath) - .map_err(|e| KeyStorageError::OSError(format!("failed to remove file: {}", e)))?; - } - - Ok(()) - } -} - -impl KeyStorage for BasicFileStorage { - fn get_keys(&self) -> crate::key_storage::KeyStorageResponse> { - KeyStorageResponse::ReceivedResult(self.get_keys_internal()) - } - - fn add_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) - } - - fn remove_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.remove_key_internal(key)) - } -} - -mod tests { - use crate::key_storage::{KeyStorage, KeyStorageResponse}; - - use super::BasicFileStorage; - - #[test] - fn test_basic() { - let kp = enostr::FullKeypair::generate().to_keypair(); - let resp = BasicFileStorage::mock().add_key(&kp); - - assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); - assert_num_storage(1); - - let resp = BasicFileStorage::mock().remove_key(&kp); - assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); - assert_num_storage(0); - } - - #[allow(dead_code)] - fn assert_num_storage(n: usize) { - let resp = BasicFileStorage::mock().get_keys(); - - if let KeyStorageResponse::ReceivedResult(Ok(vec)) = resp { - assert_eq!(vec.len(), n); - return; - } - panic!(); - } -} diff --git a/src/storage/file_key_storage.rs b/src/storage/file_key_storage.rs new file mode 100644 index 0000000..e7a2ed8 --- /dev/null +++ b/src/storage/file_key_storage.rs @@ -0,0 +1,176 @@ +use eframe::Result; +use enostr::{Keypair, Pubkey, SerializableKeypair}; + +use crate::Error; + +use super::{ + file_storage::{delete_file, write_file, Directory}, + key_storage_impl::{KeyStorageError, KeyStorageResponse}, +}; + +static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey"; + +/// An OS agnostic file key storage implementation +#[derive(Debug, PartialEq)] +pub struct FileKeyStorage { + keys_directory: Directory, + selected_key_directory: Directory, +} + +impl FileKeyStorage { + pub fn new(keys_directory: Directory, selected_key_directory: Directory) -> Self { + Self { + keys_directory, + selected_key_directory, + } + } + + fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { + write_file( + &self.keys_directory.file_path, + key.pubkey.hex(), + &serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7)) + .map_err(|e| KeyStorageError::Addition(Error::Generic(e.to_string())))?, + ) + .map_err(KeyStorageError::Addition) + } + + fn get_keys_internal(&self) -> Result, KeyStorageError> { + let keys = self + .keys_directory + .get_files() + .map_err(KeyStorageError::Retrieval)? + .values() + .filter_map(|str_key| serde_json::from_str::(str_key).ok()) + .map(|serializable_keypair| serializable_keypair.to_keypair("")) + .collect(); + Ok(keys) + } + + fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { + delete_file(&self.keys_directory.file_path, key.pubkey.hex()) + .map_err(KeyStorageError::Removal) + } + + fn get_selected_pubkey(&self) -> Result, KeyStorageError> { + let pubkey_str = self + .selected_key_directory + .get_file(SELECTED_PUBKEY_FILE_NAME.to_owned()) + .map_err(KeyStorageError::Selection)?; + + serde_json::from_str(&pubkey_str) + .map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string()))) + } + + fn select_pubkey(&self, pubkey: Option) -> Result<(), KeyStorageError> { + if let Some(pubkey) = pubkey { + write_file( + &self.selected_key_directory.file_path, + SELECTED_PUBKEY_FILE_NAME.to_owned(), + &serde_json::to_string(&pubkey.hex()) + .map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string())))?, + ) + .map_err(KeyStorageError::Selection) + } else if self + .selected_key_directory + .get_file(SELECTED_PUBKEY_FILE_NAME.to_owned()) + .is_ok() + { + // Case where user chose to have no selected pubkey, but one already exists + delete_file( + &self.selected_key_directory.file_path, + SELECTED_PUBKEY_FILE_NAME.to_owned(), + ) + .map_err(KeyStorageError::Selection) + } else { + Ok(()) + } + } +} + +impl FileKeyStorage { + pub fn get_keys(&self) -> KeyStorageResponse> { + KeyStorageResponse::ReceivedResult(self.get_keys_internal()) + } + + pub fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) + } + + pub fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.remove_key_internal(key)) + } + + pub fn get_selected_key(&self) -> KeyStorageResponse> { + KeyStorageResponse::ReceivedResult(self.get_selected_pubkey()) + } + + pub fn select_key(&self, key: Option) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.select_pubkey(key)) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use enostr::Keypair; + static CREATE_TMP_DIR: fn() -> Result = + || Ok(tempfile::TempDir::new()?.path().to_path_buf()); + + impl FileKeyStorage { + fn mock() -> Result { + Ok(Self { + keys_directory: Directory::new(CREATE_TMP_DIR()?), + selected_key_directory: Directory::new(CREATE_TMP_DIR()?), + }) + } + } + + #[test] + fn test_basic() { + let kp = enostr::FullKeypair::generate().to_keypair(); + let storage = FileKeyStorage::mock().unwrap(); + let resp = storage.add_key(&kp); + + assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); + assert_num_storage(&storage.get_keys(), 1); + + assert_eq!( + storage.remove_key(&kp), + KeyStorageResponse::ReceivedResult(Ok(())) + ); + assert_num_storage(&storage.get_keys(), 0); + } + + fn assert_num_storage(keys_response: &KeyStorageResponse>, n: usize) { + match keys_response { + KeyStorageResponse::ReceivedResult(Ok(keys)) => { + assert_eq!(keys.len(), n); + } + KeyStorageResponse::ReceivedResult(Err(_e)) => { + panic!("could not get keys"); + } + KeyStorageResponse::Waiting => { + panic!("did not receive result"); + } + } + } + + #[test] + fn test_select_key() { + let kp = enostr::FullKeypair::generate().to_keypair(); + + let storage = FileKeyStorage::mock().unwrap(); + let _ = storage.add_key(&kp); + assert_num_storage(&storage.get_keys(), 1); + + let resp = storage.select_pubkey(Some(kp.pubkey)); + assert!(resp.is_ok()); + + let resp = storage.get_selected_pubkey(); + + assert!(resp.is_ok()); + } +} diff --git a/src/storage/file_storage.rs b/src/storage/file_storage.rs new file mode 100644 index 0000000..e919174 --- /dev/null +++ b/src/storage/file_storage.rs @@ -0,0 +1,259 @@ +use std::{ + collections::{HashMap, VecDeque}, + fs::{self, File}, + io::{self, BufRead}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use crate::Error; + +pub enum DataPaths { + Log, + Setting, + Keys, + SelectedKey, +} + +impl DataPaths { + pub fn get_path(&self) -> Result { + let base_path = match self { + DataPaths::Log => dirs::data_local_dir(), + DataPaths::Setting | DataPaths::Keys | DataPaths::SelectedKey => { + dirs::config_local_dir() + } + } + .ok_or(Error::Generic( + "Could not open well known OS directory".to_owned(), + ))?; + + let specific_path = match self { + DataPaths::Log => PathBuf::from("logs"), + DataPaths::Setting => PathBuf::from("settings"), + DataPaths::Keys => PathBuf::from("storage").join("accounts"), + DataPaths::SelectedKey => PathBuf::from("storage").join("selected_account"), + }; + + Ok(base_path.join("notedeck").join(specific_path)) + } +} + +#[derive(Debug, PartialEq)] +pub struct Directory { + pub file_path: PathBuf, +} + +impl Directory { + pub fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + /// Get the files in the current directory where the key is the file name and the value is the file contents + pub fn get_files(&self) -> Result, Error> { + let dir = fs::read_dir(self.file_path.clone())?; + let map = dir + .filter_map(|f| f.ok()) + .filter(|f| f.path().is_file()) + .filter_map(|f| { + let file_name = f.file_name().into_string().ok()?; + let contents = fs::read_to_string(f.path()).ok()?; + Some((file_name, contents)) + }) + .collect(); + + Ok(map) + } + + pub fn get_file_names(&self) -> Result, Error> { + let dir = fs::read_dir(self.file_path.clone())?; + let names = dir + .filter_map(|f| f.ok()) + .filter(|f| f.path().is_file()) + .filter_map(|f| f.file_name().into_string().ok()) + .collect(); + + Ok(names) + } + + pub fn get_file(&self, file_name: String) -> Result { + let filepath = self.file_path.clone().join(file_name.clone()); + + if filepath.exists() && filepath.is_file() { + let filepath_str = filepath + .to_str() + .ok_or_else(|| Error::Generic("Could not turn path to string".to_owned()))?; + Ok(fs::read_to_string(filepath_str)?) + } else { + Err(Error::Generic(format!( + "Requested file was not found: {}", + file_name + ))) + } + } + + pub fn get_file_last_n_lines(&self, file_name: String, n: usize) -> Result { + let filepath = self.file_path.clone().join(file_name.clone()); + + if filepath.exists() && filepath.is_file() { + let file = File::open(&filepath)?; + let reader = io::BufReader::new(file); + + let mut queue: VecDeque = VecDeque::with_capacity(n); + + let mut total_lines_in_file = 0; + for line in reader.lines() { + let line = line?; + + queue.push_back(line); + + if queue.len() > n { + queue.pop_front(); + } + total_lines_in_file += 1; + } + + let output_num_lines = queue.len(); + let output = queue.into_iter().collect::>().join("\n"); + Ok(FileResult { + output, + output_num_lines, + total_lines_in_file, + }) + } else { + Err(Error::Generic(format!( + "Requested file was not found: {}", + file_name + ))) + } + } + + /// Get the file name which is most recently modified in the directory + pub fn get_most_recent(&self) -> Result, Error> { + let mut most_recent: Option<(SystemTime, String)> = None; + + for entry in fs::read_dir(&self.file_path)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_file() { + let modified = metadata.modified()?; + let file_name = entry.file_name().to_string_lossy().to_string(); + + match most_recent { + Some((last_modified, _)) if modified > last_modified => { + most_recent = Some((modified, file_name)); + } + None => { + most_recent = Some((modified, file_name)); + } + _ => {} + } + } + } + + Ok(most_recent.map(|(_, file_name)| file_name)) + } +} + +pub struct FileResult { + pub output: String, + pub output_num_lines: usize, + pub total_lines_in_file: usize, +} + +/// Write the file to the directory +pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<(), Error> { + if !directory.exists() { + fs::create_dir_all(directory)? + } + + std::fs::write(directory.join(file_name), data)?; + Ok(()) +} + +pub fn delete_file(directory: &Path, file_name: String) -> Result<(), Error> { + let file_to_delete = directory.join(file_name.clone()); + if file_to_delete.exists() && file_to_delete.is_file() { + fs::remove_file(file_to_delete).map_err(Error::Io) + } else { + Err(Error::Generic(format!( + "Requested file to delete was not found: {}", + file_name + ))) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + storage::file_storage::{delete_file, write_file}, + Error, + }; + + use super::Directory; + + static CREATE_TMP_DIR: fn() -> Result = + || Ok(tempfile::TempDir::new()?.path().to_path_buf()); + + #[test] + fn test_add_get_delete() { + if let Ok(path) = CREATE_TMP_DIR() { + let directory = Directory::new(path); + let file_name = "file_test_name.txt".to_string(); + let file_contents = "test"; + let write_res = write_file(&directory.file_path, file_name.clone(), file_contents); + assert!(write_res.is_ok()); + + if let Ok(asserted_file_contents) = directory.get_file(file_name.clone()) { + assert_eq!(asserted_file_contents, file_contents); + } else { + panic!("File not found"); + } + + let delete_res = delete_file(&directory.file_path, file_name); + assert!(delete_res.is_ok()); + } else { + panic!("could not get interactor") + } + } + + #[test] + fn test_get_multiple() { + if let Ok(path) = CREATE_TMP_DIR() { + let directory = Directory::new(path); + + for i in 0..10 { + let file_name = format!("file{}.txt", i); + let write_res = write_file(&directory.file_path, file_name, "test"); + assert!(write_res.is_ok()); + } + + if let Ok(files) = directory.get_files() { + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(files.contains_key(&file_name)); + assert_eq!(files.get(&file_name).unwrap(), "test"); + } + } else { + panic!("Files not found"); + } + + if let Ok(file_names) = directory.get_file_names() { + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(file_names.contains(&file_name)); + } + } else { + panic!("File names not found"); + } + + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(delete_file(&directory.file_path, file_name).is_ok()); + } + } else { + panic!("could not get interactor") + } + } +} diff --git a/src/storage/key_storage_impl.rs b/src/storage/key_storage_impl.rs new file mode 100644 index 0000000..ae0cae0 --- /dev/null +++ b/src/storage/key_storage_impl.rs @@ -0,0 +1,112 @@ +use enostr::{Keypair, Pubkey}; + +use super::file_key_storage::FileKeyStorage; +use crate::Error; + +#[cfg(target_os = "macos")] +use super::security_framework_key_storage::SecurityFrameworkKeyStorage; + +#[derive(Debug, PartialEq)] +pub enum KeyStorageType { + None, + FileSystem(FileKeyStorage), + #[cfg(target_os = "macos")] + SecurityFramework(SecurityFrameworkKeyStorage), +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum KeyStorageResponse { + Waiting, + ReceivedResult(Result), +} + +impl PartialEq for KeyStorageResponse { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (KeyStorageResponse::Waiting, KeyStorageResponse::Waiting) => true, + ( + KeyStorageResponse::ReceivedResult(Ok(r1)), + KeyStorageResponse::ReceivedResult(Ok(r2)), + ) => r1 == r2, + ( + KeyStorageResponse::ReceivedResult(Err(_)), + KeyStorageResponse::ReceivedResult(Err(_)), + ) => true, + _ => false, + } + } +} + +impl KeyStorageType { + pub fn get_keys(&self) -> KeyStorageResponse> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())), + Self::FileSystem(f) => f.get_keys(), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.get_keys(), + } + } + + pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + let _ = key; + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.add_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.add_key(key), + } + } + + pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + let _ = key; + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.remove_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.remove_key(key), + } + } + + pub fn get_selected_key(&self) -> KeyStorageResponse> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(None)), + Self::FileSystem(f) => f.get_selected_key(), + #[cfg(target_os = "macos")] + Self::SecurityFramework(_) => unimplemented!(), + } + } + + pub fn select_key(&self, key: Option) -> KeyStorageResponse<()> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.select_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(_) => unimplemented!(), + } + } +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum KeyStorageError { + Retrieval(Error), + Addition(Error), + Selection(Error), + Removal(Error), + OSError(Error), +} + +impl std::fmt::Display for KeyStorageError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e), + Self::Addition(key) => write!(f, "Failed to add key: {:?}", key), + Self::Selection(pubkey) => write!(f, "Failed to select key: {:?}", pubkey), + Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key), + Self::OSError(e) => write!(f, "OS had an error: {:?}", e), + } + } +} + +impl std::error::Error for KeyStorageError {} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..7eb4ce7 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,14 @@ +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod file_key_storage; +mod file_storage; + +pub use file_key_storage::FileKeyStorage; +pub use file_storage::write_file; +pub use file_storage::DataPaths; +pub use file_storage::Directory; + +#[cfg(target_os = "macos")] +mod security_framework_key_storage; + +pub mod key_storage_impl; +pub use key_storage_impl::{KeyStorageResponse, KeyStorageType}; diff --git a/src/macos_key_storage.rs b/src/storage/security_framework_key_storage.rs similarity index 71% rename from src/macos_key_storage.rs rename to src/storage/security_framework_key_storage.rs index 77f28f9..8827dee 100644 --- a/src/macos_key_storage.rs +++ b/src/storage/security_framework_key_storage.rs @@ -1,40 +1,45 @@ -#![cfg(target_os = "macos")] +use std::borrow::Cow; use enostr::{Keypair, Pubkey, SecretKey}; - -use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult}; -use security_framework::passwords::{delete_generic_password, set_generic_password}; - -use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse}; - +use security_framework::{ + item::{ItemClass, ItemSearchOptions, Limit, SearchResult}, + passwords::{delete_generic_password, set_generic_password}, +}; use tracing::error; -pub struct MacOSKeyStorage<'a> { - pub service_name: &'a str, +use crate::Error; + +use super::{key_storage_impl::KeyStorageError, KeyStorageResponse}; + +#[derive(Debug, PartialEq)] +pub struct SecurityFrameworkKeyStorage { + pub service_name: Cow<'static, str>, } -impl<'a> MacOSKeyStorage<'a> { - pub fn new(service_name: &'a str) -> Self { - MacOSKeyStorage { service_name } +impl SecurityFrameworkKeyStorage { + pub fn new(service_name: String) -> Self { + SecurityFrameworkKeyStorage { + service_name: Cow::Owned(service_name), + } } - fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> { + fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { match set_generic_password( - self.service_name, + &self.service_name, key.pubkey.hex().as_str(), key.secret_key .as_ref() .map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()), ) { Ok(_) => Ok(()), - Err(_) => Err(KeyStorageError::Addition(key.pubkey.hex())), + Err(e) => Err(KeyStorageError::Addition(Error::Generic(e.to_string()))), } } fn get_pubkey_strings(&self) -> Vec { let search_results = ItemSearchOptions::new() .class(ItemClass::generic_password()) - .service(self.service_name) + .service(&self.service_name) .load_attributes(true) .limit(Limit::All) .search(); @@ -64,7 +69,7 @@ impl<'a> MacOSKeyStorage<'a> { fn get_privkey_bytes_for(&self, account: &str) -> Option> { let search_result = ItemSearchOptions::new() .class(ItemClass::generic_password()) - .service(self.service_name) + .service(&self.service_name) .load_data(true) .account(account) .search(); @@ -97,26 +102,26 @@ impl<'a> MacOSKeyStorage<'a> { } fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> { - match delete_generic_password(self.service_name, pubkey.hex().as_str()) { + match delete_generic_password(&self.service_name, pubkey.hex().as_str()) { Ok(_) => Ok(()), Err(e) => { error!("delete key error {}", e); - Err(KeyStorageError::Removal(pubkey.hex())) + Err(KeyStorageError::Removal(Error::Generic(e.to_string()))) } } } } -impl<'a> KeyStorage for MacOSKeyStorage<'a> { - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.add_key(key)) +impl SecurityFrameworkKeyStorage { + pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) } - fn get_keys(&self) -> KeyStorageResponse> { + pub fn get_keys(&self) -> KeyStorageResponse> { KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs())) } - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey)) } } @@ -127,8 +132,8 @@ mod tests { use enostr::FullKeypair; static TEST_SERVICE_NAME: &str = "NOTEDECKTEST"; - static STORAGE: MacOSKeyStorage = MacOSKeyStorage { - service_name: TEST_SERVICE_NAME, + static STORAGE: SecurityFrameworkKeyStorage = SecurityFrameworkKeyStorage { + service_name: Cow::Borrowed(TEST_SERVICE_NAME), }; // individual tests are ignored so test runner doesn't run them all concurrently @@ -140,14 +145,14 @@ mod tests { let num_keys_before_test = STORAGE.get_pubkeys().len(); let keypair = FullKeypair::generate().to_keypair(); - let add_result = STORAGE.add_key(&keypair); - assert_eq!(add_result, Ok(())); + let add_result = STORAGE.add_key_internal(&keypair); + assert!(add_result.is_ok()); let get_pubkeys_result = STORAGE.get_pubkeys(); assert_eq!(get_pubkeys_result.len() - num_keys_before_test, 1); let remove_result = STORAGE.delete_key(&keypair.pubkey); - assert_eq!(remove_result, Ok(())); + assert!(remove_result.is_ok()); let keys = STORAGE.get_pubkeys(); assert_eq!(keys.len() - num_keys_before_test, 0); @@ -163,8 +168,8 @@ mod tests { .collect(); expected_keypairs.iter().for_each(|keypair| { - let add_result = STORAGE.add_key(keypair); - assert_eq!(add_result, Ok(())); + let add_result = STORAGE.add_key_internal(keypair); + assert!(add_result.is_ok()); }); let asserted_keypairs = STORAGE.get_all_keypairs(); @@ -172,7 +177,7 @@ mod tests { expected_keypairs.iter().for_each(|keypair| { let remove_result = STORAGE.delete_key(&keypair.pubkey); - assert_eq!(remove_result, Ok(())); + assert!(remove_result.is_ok()); }); let num_keys_after_test = STORAGE.get_all_keypairs().len();