From cfe44fd86139a00d5044650e2285cc3183eddd66 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Fri, 25 Jun 2021 18:07:21 +0200 Subject: [PATCH] feat(kvs): implement basic persistent key-value store --- espanso-kvs/Cargo.toml | 16 ++++ espanso-kvs/src/lib.rs | 104 +++++++++++++++++++++++ espanso-kvs/src/persistent.rs | 153 ++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 espanso-kvs/Cargo.toml create mode 100644 espanso-kvs/src/lib.rs create mode 100644 espanso-kvs/src/persistent.rs diff --git a/espanso-kvs/Cargo.toml b/espanso-kvs/Cargo.toml new file mode 100644 index 0000000..c22a908 --- /dev/null +++ b/espanso-kvs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "espanso-kvs" +version = "0.1.0" +authors = ["Federico Terzi "] +edition = "2018" + +[dependencies] +log = "0.4.14" +anyhow = "1.0.38" +thiserror = "1.0.23" +serde = { version = "1.0.123", features = ["derive"] } +serde_json = "1.0.62" + + +[dev-dependencies] +tempdir = "0.3.7" \ No newline at end of file diff --git a/espanso-kvs/src/lib.rs b/espanso-kvs/src/lib.rs new file mode 100644 index 0000000..efcad9d --- /dev/null +++ b/espanso-kvs/src/lib.rs @@ -0,0 +1,104 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::Path; + +use anyhow::Result; +use serde::{de::DeserializeOwned, Serialize}; + +mod persistent; + +pub trait KVS: Send + Sync { + fn get(&self, key: &str) -> Result>; + fn set(&self, key: &str, value: T) -> Result<()>; + fn delete(&self, key: &str) -> Result<()>; +} + +pub fn get_persistent(base_dir: &Path) -> Result { + persistent::PersistentJsonKVS::new(base_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + use tempdir::TempDir; + + pub fn use_test_directory(callback: impl FnOnce(&Path)) { + let dir = TempDir::new("kvstempconfig").unwrap(); + + callback( + &dir.path(), + ); + } + + #[test] + fn test_base_types() { + use_test_directory(|base_dir| { + let kvs = get_persistent(base_dir).unwrap(); + + assert_eq!(kvs.get::("my_key").unwrap().is_none(), true); + assert_eq!(kvs.get::("another_key").unwrap().is_none(), true); + + kvs.set("my_key", "test".to_string()).unwrap(); + kvs.set("another_key", false).unwrap(); + + assert_eq!(kvs.get::("my_key").unwrap().unwrap(), "test"); + assert_eq!(kvs.get::("another_key").unwrap().unwrap(), false); + + kvs.delete("my_key").unwrap(); + + assert_eq!(kvs.get::("my_key").unwrap().is_none(), true); + assert_eq!(kvs.get::("another_key").unwrap().unwrap(), false); + }); + } + + #[test] + fn test_type_mismatch() { + use_test_directory(|base_dir| { + let kvs = get_persistent(base_dir).unwrap(); + + assert_eq!(kvs.get::("my_key").unwrap().is_none(), true); + + kvs.set("my_key", "test".to_string()).unwrap(); + + assert_eq!(kvs.get::("my_key").is_err(), true); + assert_eq!(kvs.get::("my_key").is_ok(), true); + }); + } + + #[test] + fn test_delete_non_existing_key() { + use_test_directory(|base_dir| { + let kvs = get_persistent(base_dir).unwrap(); + + kvs.delete("my_key").unwrap(); + }); + } + + #[test] + fn test_invalid_key_name() { + use_test_directory(|base_dir| { + let kvs = get_persistent(base_dir).unwrap(); + + assert_eq!(kvs.get::("invalid key name").is_err(), true); + assert_eq!(kvs.get::("").is_err(), true); + }); + } +} diff --git a/espanso-kvs/src/persistent.rs b/espanso-kvs/src/persistent.rs new file mode 100644 index 0000000..ab745f0 --- /dev/null +++ b/espanso-kvs/src/persistent.rs @@ -0,0 +1,153 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use anyhow::Result; +use serde_json::Value; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; +use thiserror::Error; + +use super::KVS; + +const DEFAULT_KVS_DIR_NAME: &str = "kvs"; + +#[derive(Clone)] +pub struct PersistentJsonKVS { + kvs_dir: PathBuf, + store: Arc>>, +} + +impl PersistentJsonKVS { + pub fn new(base_dir: &Path) -> Result { + let kvs_dir = base_dir.join(DEFAULT_KVS_DIR_NAME); + if !kvs_dir.is_dir() { + std::fs::create_dir_all(&kvs_dir)?; + } + + Ok(Self { + kvs_dir, + store: Arc::new(Mutex::new(HashMap::new())), + }) + } +} + +impl KVS for PersistentJsonKVS { + fn get(&self, key: &str) -> Result> { + if !is_valid_key_name(key) { + return Err(PersistentJsonKVSError::InvalidKey(key.to_string()).into()); + } + + let mut lock = self.store.lock().expect("unable to obtain KVS read lock"); + + if let Some(cached_value) = lock.get(key) { + let converted_value = serde_json::from_value(cached_value.clone())?; + return Ok(Some(converted_value)); + } + + // Not found in the cache, read from the file + let target_file = self.kvs_dir.join(key); + if target_file.is_file() { + let content = std::fs::read_to_string(&target_file)?; + let deserialized_value: Value = serde_json::from_str(&content)?; + let converted_value = serde_json::from_value(deserialized_value.clone())?; + + lock.insert(key.to_string(), deserialized_value); + + return Ok(Some(converted_value)); + } + + Ok(None) + } + + fn set(&self, key: &str, value: T) -> Result<()> { + if !is_valid_key_name(key) { + return Err(PersistentJsonKVSError::InvalidKey(key.to_string()).into()); + } + + let mut lock = self.store.lock().expect("unable to obtain KVS write lock"); + + let serialized_value = serde_json::to_value(value)?; + let serialized_string = serde_json::to_string(&serialized_value)?; + + lock.insert(key.to_string(), serialized_value); + + let target_file = self.kvs_dir.join(key); + std::fs::write(target_file, serialized_string)?; + + Ok(()) + } + + fn delete(&self, key: &str) -> Result<()> { + if !is_valid_key_name(key) { + return Err(PersistentJsonKVSError::InvalidKey(key.to_string()).into()); + } + + let mut lock = self.store.lock().expect("unable to obtain KVS delete lock"); + + lock.remove(key); + + let target_file = self.kvs_dir.join(key); + if target_file.is_file() { + std::fs::remove_file(target_file)?; + } + + Ok(()) + } +} + +fn is_valid_key_name(key: &str) -> bool { + if key.len() == 0 || key.len() > 200 { + return false; + } + + if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return false; + } + + true +} + +#[derive(Error, Debug)] +pub enum PersistentJsonKVSError { + #[error("The provided key `{0}` is is invalid. Keys must only be composed of ascii letters, numbers and underscores.")] + InvalidKey(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_key_names() { + assert!(is_valid_key_name("key")); + assert!(is_valid_key_name("key_name")); + assert!(is_valid_key_name("Another_long_key_name_2")); + } + + #[test] + fn test_invalid_key_names() { + assert!(!is_valid_key_name("")); + assert!(!is_valid_key_name("with space")); + assert!(!is_valid_key_name("with/special")); + assert!(!is_valid_key_name("with\\special")); + } +}