chore(DX): Improve the DX #27

Merged
awiteb merged 9 commits from awiteb/improve-dx into master 2024-05-06 23:19:20 +02:00 AGit
16 changed files with 182 additions and 67 deletions
Showing only changes of commit 17974ce74b - Show all commits

View file

@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use inquire::{Password, PasswordDisplayMode};
use crate::{
vault::{Vault, Vaults},
@ -23,11 +24,14 @@ use crate::{
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Add command, used to add new vault to the vaults file
pub struct Add {
#[command(flatten)]
vault_info: Vault,
/// The password, if there is no value for it you will prompt it
#[arg(short, long)]
// FIXME: I think replacing `Option<Option<String>>` with custom type will be better
#[allow(clippy::option_option)]
password: Option<Option<String>>,
}
@ -41,10 +45,10 @@ impl LprsCommand for Add {
Some(None) => {
log::debug!("User didn't provide a password, prompting it");
self.vault_info.password = Some(
inquire::Password::new("Vault password:")
Password::new("Vault password:")
.without_confirmation()
.with_formatter(&|p| "*".repeat(p.chars().count()))
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.with_display_mode(PasswordDisplayMode::Masked)
.prompt()?,
);
}

View file

@ -22,7 +22,8 @@ use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult};
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
pub struct Clean {}
/// Clean command, used to clean the vaults file (remove all vaults)
pub struct Clean;
impl LprsCommand for Clean {
fn run(self, vault_manager: Vaults) -> LprsResult<()> {

View file

@ -17,6 +17,7 @@
use std::num::NonZeroU64;
use clap::Args;
use inquire::{Password, PasswordDisplayMode};
use crate::{
vault::{Vault, Vaults},
@ -25,6 +26,7 @@ use crate::{
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Edit command, used to edit the vault content
pub struct Edit {
/// The password index. Check it from list command
index: NonZeroU64,
@ -37,6 +39,8 @@ pub struct Edit {
username: Option<String>,
#[arg(short, long)]
/// The new password, if there is no value for it you will prompt it
// FIXME: I think replacing `Option<Option<String>>` with custom type will be better
#[allow(clippy::option_option)]
password: Option<Option<String>>,
#[arg(short, long)]
/// The new vault service
@ -63,10 +67,10 @@ impl LprsCommand for Edit {
let password = match self.password {
Some(Some(password)) => Some(password),
Some(None) => Some(
inquire::Password::new("New vault password:")
Password::new("New vault password:")
.without_confirmation()
.with_formatter(&|p| "*".repeat(p.chars().count()))
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.with_display_mode(PasswordDisplayMode::Masked)
.prompt()?,
),
None => None,

View file

@ -25,6 +25,8 @@ use crate::{
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Export command, used to export the vaults in `lprs` format or `BitWarden` format.
/// The exported file will be a json file.
pub struct Export {
/// The path to export to
path: PathBuf,
@ -51,24 +53,29 @@ impl LprsCommand for Export {
}
fn validate_args(&self) -> LprsResult<()> {
if self
if !self
.path
.extension()
.is_some_and(|e| e.to_string_lossy().eq_ignore_ascii_case("json"))
{
if !self.path.exists() {
Ok(())
} else {
Err(LprsError::Io(IoError::new(
IoErrorKind::AlreadyExists,
format!("file `{}` is already exists", self.path.display()),
)))
}
} else {
Err(LprsError::Io(IoError::new(
return Err(LprsError::Io(IoError::new(
IoErrorKind::InvalidInput,
format!("file `{}` is not a json file", self.path.display()),
)))
)));
}
if self.path.exists() {
return Err(LprsError::Io(IoError::new(
IoErrorKind::AlreadyExists,
format!("file `{}` is already exists", self.path.display()),
)));
}
if self.path.is_dir() {
return Err(LprsError::Io(IoError::new(
IoErrorKind::InvalidInput,
format!("file `{}` is a directory", self.path.display()),
)));
}
Ok(())
}
}

View file

@ -22,6 +22,7 @@ use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult};
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Generate command, used to generate a password
pub struct Gen {
/// The password length
#[arg(default_value_t = NonZeroU64::new(18).unwrap())]

View file

@ -30,6 +30,7 @@ use crate::{
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Import command, used to import vaults from the exported files, `lprs` or `BitWarden`
pub struct Import {
/// The file path to import from
path: PathBuf,
@ -77,24 +78,29 @@ impl LprsCommand for Import {
}
fn validate_args(&self) -> LprsResult<()> {
if self.path.exists() {
if self
.path
.extension()
.is_some_and(|e| e.to_string_lossy().eq_ignore_ascii_case("json"))
{
Ok(())
} else {
Err(LprsError::Io(IoError::new(
IoErrorKind::InvalidInput,
format!("file `{}` is not a json file", self.path.display()),
)))
}
} else {
Err(LprsError::Io(IoError::new(
if self
.path
.extension()
.is_some_and(|e| e.to_string_lossy().eq_ignore_ascii_case("json"))
{
return Err(LprsError::Io(IoError::new(
IoErrorKind::InvalidInput,
format!("file `{}` is not a json file", self.path.display()),
)));
}
if !self.path.exists() {
return Err(LprsError::Io(IoError::new(
IoErrorKind::NotFound,
format!("file `{}` not found", self.path.display()),
)))
)));
}
if self.path.is_dir() {
return Err(LprsError::Io(IoError::new(
IoErrorKind::InvalidInput,
format!("file `{}` is a directory", self.path.display()),
)));
}
Ok(())
}
}

View file

@ -23,6 +23,7 @@ use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult};
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// List command, used to list the vaults and search
pub struct List {
/// Return the password with spesifc index
#[arg(short, long, value_name = "INDEX")]

View file

@ -20,13 +20,22 @@ use clap::Parser;
use crate::{impl_commands, utils, vault::Vaults, LprsCommand, LprsResult};
/// Add command, used to add new vault to the vaults file
pub mod add_command;
/// Clean command, used to clean the vaults file (remove all vaults)
pub mod clean_command;
/// Edit command, used to edit the vault content
pub mod edit_command;
/// Export command, used to export the vaults
/// in `lprs` format or `BitWarden` format
pub mod export_command;
/// Generate command, used to generate a password
pub mod gen_command;
/// Import command, used to import vaults from the exported files, `lprs` or `BitWarden`
pub mod import_command;
/// List command, used to list the vaults and search
pub mod list_command;
/// Remove command, used to remove vault from the vaults file
pub mod remove_command;
/// The lprs commands
@ -56,6 +65,7 @@ impl_commands!(Commands, Add Remove List Clean Edit Gen Export Import);
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
/// The lprs cli, manage the CLI arguments and run the commands
pub struct Cli {
/// The vaults json file
#[arg(short = 'f', long)]
@ -65,11 +75,19 @@ pub struct Cli {
pub verbose: bool,
#[command(subcommand)]
/// The provided command to run
pub command: Commands,
}
impl Cli {
/// Run the cli
///
/// # Errors
/// - If can't get the default vaults file
/// - If the vaults file can't be created
/// - If the user provide a worng CLI arguments
/// - If failed to write in the vaults file
/// - (errors from the commands)
pub fn run(self) -> LprsResult<()> {
let vaults_file = if let Some(path) = self.vaults_file {
log::info!("Using the given vaults file");
@ -83,7 +101,7 @@ impl Cli {
path
} else {
log::info!("Using the default vaults file");
crate::utils::vaults_file()?
utils::vaults_file()?
};
log::debug!("Vaults file: {}", vaults_file.display());

View file

@ -22,6 +22,7 @@ use crate::{vault::Vaults, LprsCommand, LprsError, LprsResult};
#[derive(Debug, Args)]
#[command(author, version, about, long_about = None)]
/// Remove command, used to remove a vault from the vaults file
pub struct Remove {
/// The password index
index: NonZeroU64,
@ -37,14 +38,14 @@ impl LprsCommand for Remove {
log::debug!("Removing vault at index: {index}");
if index > vault_manager.vaults.len() {
if !self.force {
return Err(LprsError::Other(
"The index is greater than the passwords counts".to_owned(),
));
} else {
if self.force {
log::error!(
"The index is greater than the passwords counts, but the force flag is enabled"
);
} else {
return Err(LprsError::Other(
"The index is greater than the passwords counts".to_owned(),
));
}
} else {
vault_manager.vaults.remove(index);

View file

@ -14,11 +14,13 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
use std::{process::ExitCode, string::FromUtf8Error};
use std::{io, process::ExitCode, result, string::FromUtf8Error};
pub type Result<T> = std::result::Result<T, Error>;
/// The result type used in the whole project
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
#[allow(missing_docs)]
pub enum Error {
#[error("Encryption Error: {0}")]
Encryption(String),
@ -50,12 +52,12 @@ pub enum Error {
#[error("Project Folder Error: {0}")]
ProjectDir(String),
#[error("IO Error: {0}")]
Io(#[from] std::io::Error),
Io(#[from] io::Error),
}
impl Error {
/// Return the exit code of the error
pub fn exit_code(&self) -> ExitCode {
pub const fn exit_code(&self) -> ExitCode {
// TODO: Exit with more specific exit code
ExitCode::FAILURE
}

View file

@ -46,7 +46,6 @@
/// }
/// }
/// }
/// ```
#[macro_export]
macro_rules! impl_commands {

View file

@ -13,14 +13,20 @@
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
#![doc = include_str!("../README.md")]
use clap::Parser;
use inquire::InquireError;
use std::env;
use std::process::ExitCode;
/// The main module of the lprs crate, contains the cli and the commands.
pub mod cli;
/// The errors module, contains the errors and the result type.
pub mod errors;
/// The utils module, contains the utility functions of all the modules.
pub mod utils;
/// The vault module, contains the vault struct and the vaults manager.
pub mod vault;
mod macros;
@ -30,17 +36,20 @@ pub use base64::engine::general_purpose::STANDARD as BASE64;
pub use errors::{Error as LprsError, Result as LprsResult};
pub use traits::*;
/// The default vaults file name. Used to store the vaults.
pub const DEFAULT_VAULTS_FILE: &str = "vaults.lprs";
#[cfg(feature = "update-notify")]
/// The version of the lprs crate.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(feature = "update-notify")]
/// The last version check file. Used to store the last version check time.
pub const LAST_VERSION_CHECK_FILE: &str = ".last_version_check";
fn main() -> ExitCode {
let lprs_cli = cli::Cli::parse();
if lprs_cli.verbose {
std::env::set_var("RUST_LOG", "lprs");
env::set_var("RUST_LOG", "lprs");
}
pretty_env_logger::init();

View file

@ -17,6 +17,7 @@
use crate::{vault::Vaults, LprsResult};
/// Trait to work with the commands
#[allow(clippy::missing_errors_doc)]
pub trait LprsCommand {
/// Run the command, should do all the logic, even the export
fn run(self, vault_manager: Vaults) -> LprsResult<()>;

View file

@ -16,12 +16,20 @@
use std::{fs, path::PathBuf};
use inquire::validator::Validation;
use inquire::{validator::Validation, PasswordDisplayMode};
use passwords::{analyzer, scorer};
use sha2::Digest;
#[cfg(feature = "update-notify")]
use reqwest::blocking::Client as BlockingClient;
use crate::{LprsError, LprsResult};
/// Returns the local project dir joined with the given file name
///
/// ## Errors
/// - If the project dir can't be extracted from the OS
/// - If the local project dir can't be created
pub fn local_project_file(filename: &str) -> LprsResult<PathBuf> {
let local_dir = directories::ProjectDirs::from("", "", "lprs")
.map(|d| d.data_local_dir().to_path_buf())
@ -37,6 +45,10 @@ pub fn local_project_file(filename: &str) -> LprsResult<PathBuf> {
}
/// Returns the default vaults json file
///
/// ## Errors
/// - If the project dir can't be extracted from the OS
/// - If the vaults file can't be created
pub fn vaults_file() -> LprsResult<PathBuf> {
let vaults_file = local_project_file(crate::DEFAULT_VAULTS_FILE)?;
if !vaults_file.exists() {
@ -50,22 +62,26 @@ pub fn vaults_file() -> LprsResult<PathBuf> {
/// ## To pass
/// - The length must be higher than 14 (>=15)
/// - Its score must be greater than 80.0
///
/// ## Errors
/// - There is no errors, just the return type of inquire validator
/// must be Result<Validation, inquire::CustomUserError>
pub fn password_validator(password: &str) -> Result<Validation, inquire::CustomUserError> {
let analyzed = passwords::analyzer::analyze(password);
if analyzed.length() < 15 {
return Ok(Validation::Invalid(
"The master password length must be beggier then 15".into(),
));
} else if passwords::scorer::score(&analyzed) < 80.0 {
return Ok(Validation::Invalid(
"Your master password is not stronge enough".into(),
));
}
Ok(Validation::Valid)
let analyzed = analyzer::analyze(password);
Ok(if analyzed.length() < 15 {
Validation::Invalid("The master password length must be beggier then 15".into())
} else if scorer::score(&analyzed) < 80.0 {
Validation::Invalid("Your master password is not stronge enough".into())
} else {
Validation::Valid
})
}
/// Ask the user for the master password, then returns it
///
/// ## Errors
/// - Can't read the password from the user
///
/// Return's the password as 32 bytes after hash it (256 bit)
pub fn master_password_prompt(is_new_vaults_file: bool) -> LprsResult<[u8; 32]> {
inquire::Password {
@ -79,13 +95,17 @@ pub fn master_password_prompt(is_new_vaults_file: bool) -> LprsResult<[u8; 32]>
..inquire::Password::new("")
}
.with_formatter(&|p| "*".repeat(p.chars().count()))
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.with_display_mode(PasswordDisplayMode::Masked)
.prompt()
.map(|p| sha2::Sha256::digest(p).into())
.map_err(Into::into)
}
/// Retuns the current lprs version from `crates.io`
///
/// ## Errors
/// - The project dir can't be extracted from the OS
/// - If the last version check file can't be created
#[cfg(feature = "update-notify")]
pub fn lprs_version() -> LprsResult<Option<String>> {
use std::time::SystemTime;
@ -108,7 +128,7 @@ pub fn lprs_version() -> LprsResult<Option<String>> {
// Check if the last check is before one hour or not
if (current_time - last_check) >= (60 * 60) || current_time == last_check {
if let Ok(Ok(response)) = reqwest::blocking::Client::new()
if let Ok(Ok(response)) = BlockingClient::new()
.get("https://crates.io/api/v1/crates/lprs")
.header(
"User-Agent",
@ -117,8 +137,8 @@ pub fn lprs_version() -> LprsResult<Option<String>> {
.send()
.map(|r| r.text())
{
let re =
regex::Regex::new(r#""max_stable_version":"(?<version>\d+\.\d+\.\d+)""#).unwrap();
let re = regex::Regex::new(r#""max_stable_version":"(?<version>\d+\.\d+\.\d+)""#)
.expect("The regex is correct");
if let Some(cap) = re.captures(&response) {
return Ok(cap.name("version").map(|m| m.as_str().to_string()));
}

View file

@ -1,3 +1,23 @@
// Lprs - A local CLI vault manager
// Copyright (C) 2024 Awiteb <a@4rs.nl>
//
// This program 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.
//
// This program 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 this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
// This file is not important, it is just a struct that is used to serialize and deserialize the vaults
// from and to the BitWarden format.
#![allow(missing_docs)]
use serde::{Deserialize, Serialize};
use super::{Vault, Vaults};

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
use std::{fs, path::PathBuf};
use std::{fmt, fs, path::PathBuf};
use base64::Engine;
use clap::{Parser, ValueEnum};
@ -22,6 +22,7 @@ use serde::{Deserialize, Serialize};
use crate::{LprsError, LprsResult};
/// The chiper module, used to encrypt and decrypt the vaults
pub mod cipher;
mod bitwarden;
@ -29,8 +30,12 @@ mod bitwarden;
pub use bitwarden::*;
#[derive(Clone, Debug, ValueEnum)]
/// The vaults format
pub enum Format {
/// The lprs format, which is the default format
/// and is is the result of the serialization/deserialization of the Vaults struct
Lprs,
/// The BitWarden format, which is the result of the serialization/deserialization of the BitWardenPasswords struct
BitWarden,
}
@ -85,7 +90,7 @@ impl Vault {
/// Return the name of the vault with the service if there
pub fn list_name(&self) -> String {
use std::fmt::Write;
use fmt::Write;
let mut list_name = self.name.clone();
if let Some(ref username) = self.username {
write!(&mut list_name, " <{username}>").expect("String never fail");
@ -119,6 +124,10 @@ impl Vaults {
///
/// This function used to backup the vaults.
///
/// ## Errors
/// - If the serialization failed
/// - if the encryption failed
///
/// Note: The returned string is `Vec<Vault>`
pub fn json_export(&self) -> LprsResult<String> {
let encrypt = |val: &str| {
@ -147,6 +156,10 @@ impl Vaults {
/// Reload the vaults from json data.
///
/// ## Errors
/// - If base64 decoding failed (of the vault field encrypted data)
/// - If decryption failed (wrong master password or the data is corrupted)
///
/// This function used to import backup vaults.
pub fn json_reload(master_password: &[u8; 32], json_data: &[u8]) -> LprsResult<Vec<Vault>> {
let decrypt = |val: &str| {
@ -172,6 +185,9 @@ impl Vaults {
}
/// Encrypt the vaults then export it to the file
///
/// ## Errors
/// - Writing to the file failed
pub fn try_export(self) -> LprsResult<()> {
log::debug!(
"Trying to export the vaults to the file: {}",
@ -185,6 +201,11 @@ impl Vaults {
}
/// Reload the vaults from the file then decrypt it
///
/// ## Errors
/// - Reading the file failed
/// - Decryption failed (wrong master password or the data is corrupted)
/// - Bytecode deserialization failed (the data is corrupted)
pub fn try_reload(vaults_file: PathBuf, master_password: [u8; 32]) -> LprsResult<Self> {
let vaults_data = fs::read(&vaults_file)?;
@ -198,8 +219,8 @@ impl Vaults {
}
}
impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
@ -210,8 +231,8 @@ impl std::fmt::Display for Format {
}
}
impl std::fmt::Display for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl fmt::Display for Vault {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Name: {}", self.name)?;
if let Some(ref username) = self.username {
write!(f, "\nUsername: {username}")?;