termscp/src/system/bookmarks_client.rs
2021-09-01 15:20:05 +02:00

785 lines
27 KiB
Rust

//! ## BookmarksClient
//!
//! `bookmarks_client` is the module which provides an API between the Bookmarks module and the system
/**
* MIT License
*
* termscp - Copyright (c) 2021 Christian Visintin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// Crate
#[cfg(feature = "with-keyring")]
use super::keys::keyringstorage::KeyringStorage;
use super::keys::{filestorage::FileStorage, KeyStorage, KeyStorageError};
// Local
use crate::config::{
bookmarks::{Bookmark, UserHosts},
serialization::{deserialize, serialize, SerializerError, SerializerErrorKind},
};
use crate::filetransfer::FileTransferParams;
use crate::utils::crypto;
use crate::utils::fmt::fmt_time;
use crate::utils::random::random_alphanumeric_with_len;
// Ext
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::string::ToString;
use std::time::SystemTime;
/// ## BookmarksClient
///
/// BookmarksClient provides a layer between the host system and the bookmarks module
pub struct BookmarksClient {
hosts: UserHosts,
bookmarks_file: PathBuf,
key: String,
recents_size: usize,
}
impl BookmarksClient {
/// ### BookmarksClient
///
/// Instantiates a new BookmarksClient
/// Bookmarks file path must be provided
/// Storage path for file provider must be provided
pub fn new(
bookmarks_file: &Path,
storage_path: &Path,
recents_size: usize,
) -> Result<BookmarksClient, SerializerError> {
// Create default hosts
let default_hosts: UserHosts = UserHosts::default();
debug!("Setting up bookmarks client...");
// Make a key storage (with-keyring)
#[cfg(feature = "with-keyring")]
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
debug!("Setting up KeyStorage");
let username: String = whoami::username();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
// Check if keyring storage is supported
#[cfg(not(test))]
let app_name: &str = "termscp";
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "termscp-test";
match storage.is_supported() {
true => {
debug!("Using KeyringStorage");
(Box::new(storage), app_name)
}
false => {
warn!("KeyringStorage is not supported; using FileStorage");
(Box::new(FileStorage::new(storage_path)), "bookmarks")
}
}
};
// Make a key storage (wno-keyring)
#[cfg(not(feature = "with-keyring"))]
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
#[cfg(not(test))]
let app_name: &str = "bookmarks";
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "bookmarks-test";
debug!("Using FileStorage");
(Box::new(FileStorage::new(storage_path)), app_name)
};
// Load key
let key: String = match key_storage.get_key(service_id) {
Ok(k) => {
debug!("Key loaded with success");
k
}
Err(e) => match e {
KeyStorageError::NoSuchKey => {
// If no such key, generate key and set it into the storage
let key: String = Self::generate_key();
debug!("Key doesn't exist yet or could not be loaded; generated a new key");
if let Err(e) = key_storage.set_key(service_id, key.as_str()) {
error!("Failed to set new key into storage: {}", e);
return Err(SerializerError::new_ex(
SerializerErrorKind::Io,
format!("Could not write key to storage: {}", e),
));
}
// Return key
key
}
_ => {
error!("Failed to get key from storage: {}", e);
return Err(SerializerError::new_ex(
SerializerErrorKind::Io,
format!("Could not get key from storage: {}", e),
));
}
},
};
let mut client: BookmarksClient = BookmarksClient {
hosts: default_hosts,
bookmarks_file: PathBuf::from(bookmarks_file),
key,
recents_size,
};
// If bookmark file doesn't exist, initialize it
if !bookmarks_file.exists() {
info!("Bookmarks file doesn't exist yet; creating it...");
if let Err(err) = client.write_bookmarks() {
error!("Failed to create bookmarks file: {}", err);
return Err(err);
}
} else {
// Load bookmarks from file
if let Err(err) = client.read_bookmarks() {
error!("Failed to load bookmarks: {}", err);
return Err(err);
}
}
info!("Bookmarks client initialized");
// Load key
Ok(client)
}
/// ### iter_bookmarks
///
/// Iterate over bookmarks keys
pub fn iter_bookmarks(&self) -> impl Iterator<Item = &String> + '_ {
Box::new(self.hosts.bookmarks.keys())
}
/// ### get_bookmark
///
/// Get bookmark associated to key
pub fn get_bookmark(&self, key: &str) -> Option<FileTransferParams> {
debug!("Getting bookmark {}", key);
let mut entry: Bookmark = self.hosts.bookmarks.get(key).cloned()?;
// Decrypt password first
if let Some(pwd) = entry.password.as_mut() {
match self.decrypt_str(pwd.as_str()) {
Ok(decrypted_pwd) => {
*pwd = decrypted_pwd;
}
Err(err) => {
error!("Failed to decrypt password for bookmark: {}", err);
}
}
}
// Then convert into
Some(FileTransferParams::from(entry))
}
/// ### add_recent
///
/// Add a new recent to bookmarks
pub fn add_bookmark<S: AsRef<str>>(
&mut self,
name: S,
params: FileTransferParams,
save_password: bool,
) {
let name: String = name.as_ref().to_string();
if name.is_empty() {
error!("Fatal error; bookmark name is empty");
panic!("Bookmark name can't be empty");
}
// Make bookmark
info!("Added bookmark {}", name);
let mut host: Bookmark = self.make_bookmark(params);
// If not save_password, set password to `None`
if !save_password {
host.password = None;
}
self.hosts.bookmarks.insert(name, host);
}
/// ### del_bookmark
///
/// Delete entry from bookmarks
pub fn del_bookmark(&mut self, name: &str) {
let _ = self.hosts.bookmarks.remove(name);
info!("Removed bookmark {}", name);
}
/// ### iter_recents
///
/// Iterate over recents keys
pub fn iter_recents(&self) -> impl Iterator<Item = &String> + '_ {
Box::new(self.hosts.recents.keys())
}
/// ### get_recent
///
/// Get recent associated to key
pub fn get_recent(&self, key: &str) -> Option<FileTransferParams> {
// NOTE: password is not decrypted; recents will never have password
info!("Getting bookmark {}", key);
let entry: Bookmark = self.hosts.recents.get(key).cloned()?;
Some(FileTransferParams::from(entry))
}
/// ### add_recent
///
/// Add a new recent to bookmarks
pub fn add_recent(&mut self, params: FileTransferParams) {
// Make bookmark
let mut host: Bookmark = self.make_bookmark(params);
// Null password for recents
host.password = None;
// Check if duplicated
for (key, value) in &self.hosts.recents {
if *value == host {
debug!("Discarding recent since duplicated ({})", key);
// Don't save duplicates
return;
}
}
// If hosts size is bigger than self.recents_size; pop last
if self.hosts.recents.len() >= self.recents_size {
// Get keys
let mut keys: Vec<String> = Vec::with_capacity(self.hosts.recents.len());
for key in self.hosts.recents.keys() {
keys.push(key.clone());
}
// Sort keys; NOTE: most recent is the last element
keys.sort();
// Delete keys starting from the last one
for key in keys.iter() {
let _ = self.hosts.recents.remove(key);
debug!("Removed recent bookmark {}", key);
// If length is < self.recents_size; break
if self.hosts.recents.len() < self.recents_size {
break;
}
}
}
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
info!("Saved recent host {}", name);
self.hosts.recents.insert(name, host);
}
/// ### del_recent
///
/// Delete entry from recents
pub fn del_recent(&mut self, name: &str) {
let _ = self.hosts.recents.remove(name);
info!("Removed recent host {}", name);
}
/// ### write_bookmarks
///
/// Write bookmarks to file
pub fn write_bookmarks(&self) -> Result<(), SerializerError> {
// Open file
debug!("Writing bookmarks");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.bookmarks_file.as_path())
{
Ok(writer) => serialize(&self.hosts, Box::new(writer)),
Err(err) => {
error!("Failed to write bookmarks: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::Io,
err.to_string(),
))
}
}
}
/// ### read_bookmarks
///
/// Read bookmarks from file
fn read_bookmarks(&mut self) -> Result<(), SerializerError> {
// Open bookmarks file for read
debug!("Reading bookmarks");
match OpenOptions::new()
.read(true)
.open(self.bookmarks_file.as_path())
{
Ok(reader) => {
// Deserialize
match deserialize(Box::new(reader)) {
Ok(hosts) => {
self.hosts = hosts;
Ok(())
}
Err(err) => Err(err),
}
}
Err(err) => {
error!("Failed to read bookmarks: {}", err);
Err(SerializerError::new_ex(
SerializerErrorKind::Io,
err.to_string(),
))
}
}
}
/// ### generate_key
///
/// Generate a new AES key
fn generate_key() -> String {
// Generate 256 bytes (2048 bits) key
random_alphanumeric_with_len(256)
}
/// ### make_bookmark
///
/// Make bookmark from credentials
fn make_bookmark(&self, params: FileTransferParams) -> Bookmark {
let mut bookmark: Bookmark = Bookmark::from(params);
// Encrypt password
if let Some(pwd) = bookmark.password {
bookmark.password = Some(self.encrypt_str(pwd.as_str()));
}
bookmark
}
/// ### encrypt_str
///
/// Encrypt provided string using AES-128. Encrypted buffer is then converted to BASE64
fn encrypt_str(&self, txt: &str) -> String {
crypto::aes128_b64_crypt(self.key.as_str(), txt)
}
/// ### decrypt_str
///
/// Decrypt provided string using AES-128
fn decrypt_str(&self, secret: &str) -> Result<String, SerializerError> {
match crypto::aes128_b64_decrypt(self.key.as_str(), secret) {
Ok(txt) => Ok(txt),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::Syntax,
err.to_string(),
)),
}
}
}
#[cfg(test)]
#[cfg(not(target_os = "macos"))] // CI/CD blocks
mod tests {
use super::*;
use crate::filetransfer::params::GenericProtocolParams;
use crate::filetransfer::{FileTransferProtocol, ProtocolParams};
use pretty_assertions::assert_eq;
use std::thread::sleep;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_system_bookmarks_new() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Verify client
assert_eq!(client.hosts.bookmarks.len(), 0);
assert_eq!(client.hosts.recents.len(), 0);
assert_eq!(client.key.len(), 256);
assert_eq!(client.bookmarks_file, cfg_path);
assert_eq!(client.recents_size, 16);
}
#[test]
#[cfg(any(
target_os = "linux",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
fn test_system_bookmarks_new_err() {
assert!(BookmarksClient::new(
Path::new("/tmp/oifoif/omar"),
Path::new("/tmp/efnnu/omar"),
16
)
.is_err());
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
assert!(
BookmarksClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar"), 16).is_err()
);
}
#[test]
fn test_system_bookmarks_new_from_existing() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add some bookmarks
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
);
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
let recent_key: String = String::from(client.iter_recents().next().unwrap());
assert!(client.write_bookmarks().is_ok());
let key: String = client.key.clone();
// Re-initialize a client
let client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Verify it loaded parameters correctly
assert_eq!(client.key, key);
let bookmark = ftparams_to_tup(client.get_bookmark("raspberry").unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
let bookmark = ftparams_to_tup(client.get_recent(&recent_key).unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(bookmark.4, None);
}
#[test]
fn test_system_bookmarks_manipulate_bookmarks() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
);
client.add_bookmark(
"raspberry2",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword2"),
),
true,
);
// Iter
assert_eq!(client.iter_bookmarks().count(), 2);
// Get bookmark
let bookmark = ftparams_to_tup(client.get_bookmark(&String::from("raspberry")).unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
// Delete bookmark
client.del_bookmark(&String::from("raspberry"));
// Get unexisting bookmark
assert!(client.get_bookmark(&String::from("raspberry")).is_none());
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
}
#[test]
#[should_panic]
fn test_system_bookmarks_bad_bookmark_name() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
);
}
#[test]
fn save_bookmark_wno_password() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
false,
);
let bookmark = ftparams_to_tup(client.get_bookmark(&String::from("raspberry")).unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(bookmark.4, None);
}
#[test]
fn test_system_bookmarks_manipulate_recents() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
// Iter
assert_eq!(client.iter_recents().count(), 1);
let key: String = String::from(client.iter_recents().next().unwrap());
// Get bookmark
let bookmark = ftparams_to_tup(client.get_recent(&key).unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(bookmark.4, None);
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
// Delete bookmark
client.del_recent(&key);
// Get unexisting bookmark
assert!(client.get_bookmark(&key).is_none());
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
}
#[test]
fn test_system_bookmarks_dup_recent() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
// There should be only one recent
assert_eq!(client.iter_recents().count(), 1);
}
#[test]
fn test_system_bookmarks_recents_more_than_limit() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 2).unwrap();
// Add recent, wait 1 second for each one (cause the name depends on time)
// 1
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.1",
22,
"pi",
Some("mypassword"),
));
sleep(Duration::from_secs(1));
// 2
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.2",
22,
"pi",
Some("mypassword"),
));
sleep(Duration::from_secs(1));
// 3
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.3",
22,
"pi",
Some("mypassword"),
));
// Limit is 2
assert_eq!(client.iter_recents().count(), 2);
// Check that 192.168.1.1 has been removed
let key: String = client.iter_recents().nth(0).unwrap().to_string();
assert!(matches!(
client
.hosts
.recents
.get(&key)
.unwrap()
.address
.as_ref()
.cloned()
.unwrap_or_default()
.as_str(),
"192.168.1.2" | "192.168.1.3"
));
let key: String = client.iter_recents().nth(1).unwrap().to_string();
assert!(matches!(
client
.hosts
.recents
.get(&key)
.unwrap()
.address
.as_ref()
.cloned()
.unwrap_or_default()
.as_str(),
"192.168.1.2" | "192.168.1.3"
));
}
#[test]
#[should_panic]
fn test_system_bookmarks_add_bookmark_empty() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
);
}
#[test]
fn test_system_bookmarks_decrypt_str() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
client.key = "MYSUPERSECRETKEY".to_string();
assert_eq!(
client.decrypt_str("z4Z6LpcpYqBW4+bkIok+5A==").ok().unwrap(),
"Hello world!"
);
assert!(client.decrypt_str("bidoof").is_err());
}
/// ### get_paths
///
/// Get paths for configuration and key for bookmarks
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let k: PathBuf = PathBuf::from(dir);
let mut c: PathBuf = k.clone();
c.push("bookmarks.toml");
(c, k)
}
fn make_generic_ftparams(
protocol: FileTransferProtocol,
address: &str,
port: u16,
username: &str,
password: Option<&str>,
) -> FileTransferParams {
let params = ProtocolParams::Generic(
GenericProtocolParams::default()
.address(address)
.port(port)
.username(Some(username))
.password(password),
);
FileTransferParams::new(protocol, params)
}
fn ftparams_to_tup(
params: FileTransferParams,
) -> (String, u16, FileTransferProtocol, String, Option<String>) {
let protocol = params.protocol;
let p = params.params.generic_params().unwrap();
(
p.address.to_string(),
p.port,
protocol,
p.username.as_ref().cloned().unwrap_or_default(),
p.password.as_ref().cloned(),
)
}
}