feat: Support custom fields #31
8 changed files with 176 additions and 49 deletions
30
src/clap_parsers.rs
Normal file
30
src/clap_parsers.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// 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>.
|
||||||
|
|
||||||
|
use crate::{LprsError, LprsResult};
|
||||||
|
|
||||||
|
/// Parse the key & value arguments.
|
||||||
|
/// ## Errors
|
||||||
|
/// - If the argument value syntax not `key=value`
|
||||||
|
pub fn kv_parser(value: &str) -> LprsResult<(String, String)> {
|
||||||
|
if let Some((key, value)) = value.split_once('=') {
|
||||||
|
Ok((key.trim().to_owned(), value.trim().to_owned()))
|
||||||
|
} else {
|
||||||
|
Err(LprsError::ArgParse(
|
||||||
|
"There is no value, the syntax is `KEY=VALUE`".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,7 @@
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
clap_parsers,
|
||||||
utils,
|
utils,
|
||||||
vault::{Vault, Vaults},
|
vault::{Vault, Vaults},
|
||||||
LprsCommand,
|
LprsCommand,
|
||||||
|
@ -34,11 +35,16 @@ pub struct Add {
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
#[allow(clippy::option_option)]
|
#[allow(clippy::option_option)]
|
||||||
password: Option<Option<String>>,
|
password: Option<Option<String>>,
|
||||||
|
/// Add a custom field to the vault
|
||||||
|
#[arg(name = "KEY=VALUE", short = 'c', long = "custom")]
|
||||||
|
#[arg(value_parser = clap_parsers::kv_parser)]
|
||||||
|
custom_fields: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LprsCommand for Add {
|
impl LprsCommand for Add {
|
||||||
fn run(mut self, mut vault_manager: Vaults) -> LprsResult<()> {
|
fn run(mut self, mut vault_manager: Vaults) -> LprsResult<()> {
|
||||||
self.vault_info.password = utils::user_password(self.password, "Vault password:")?;
|
self.vault_info.password = utils::user_password(self.password, "Vault password:")?;
|
||||||
|
self.vault_info.custom_fields = self.custom_fields.into_iter().collect();
|
||||||
vault_manager.add_vault(self.vault_info);
|
vault_manager.add_vault(self.vault_info);
|
||||||
vault_manager.try_export()
|
vault_manager.try_export()
|
||||||
}
|
}
|
||||||
|
@ -48,9 +54,17 @@ impl LprsCommand for Add {
|
||||||
&& self.password.is_none()
|
&& self.password.is_none()
|
||||||
&& self.vault_info.service.is_none()
|
&& self.vault_info.service.is_none()
|
||||||
&& self.vault_info.note.is_none()
|
&& self.vault_info.note.is_none()
|
||||||
|
&& self.custom_fields.is_empty()
|
||||||
{
|
{
|
||||||
return Err(LprsError::Other("You can't add empty vault".to_owned()));
|
return Err(LprsError::Other("You can't add empty vault".to_owned()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(duplicated_key) = utils::get_duplicated_field(&self.custom_fields) {
|
||||||
|
return Err(LprsError::Other(format!(
|
||||||
|
"Duplication error: The custom key `{duplicated_key}` is duplicate"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,19 +18,13 @@ use std::num::NonZeroU64;
|
||||||
|
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
|
|
||||||
use crate::{
|
use crate::{clap_parsers, utils, vault::Vaults, LprsCommand, LprsError, LprsResult};
|
||||||
utils,
|
|
||||||
vault::{Vault, Vaults},
|
|
||||||
LprsCommand,
|
|
||||||
LprsError,
|
|
||||||
LprsResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
/// Edit command, used to edit the vault content
|
/// Edit command, used to edit the vault content
|
||||||
pub struct Edit {
|
pub struct Edit {
|
||||||
/// The password index. Check it from list command
|
/// The password index. You can get it from the list command
|
||||||
index: NonZeroU64,
|
index: NonZeroU64,
|
||||||
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
|
@ -49,6 +43,12 @@ pub struct Edit {
|
||||||
#[arg(short = 'o', long)]
|
#[arg(short = 'o', long)]
|
||||||
/// The new vault note
|
/// The new vault note
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
|
/// The custom field, make its value empty to delete it
|
||||||
|
///
|
||||||
|
/// If the custom field not exist will created it, if it's will update it
|
||||||
|
#[arg(name = "KEY=VALUE", short = 'c', long = "custom")]
|
||||||
|
#[arg(value_parser = clap_parsers::kv_parser)]
|
||||||
|
pub custom_fields: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LprsCommand for Edit {
|
impl LprsCommand for Edit {
|
||||||
|
@ -65,15 +65,23 @@ impl LprsCommand for Edit {
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!("Applying the new values to the vault");
|
log::info!("Applying the new values to the vault");
|
||||||
*vault = Vault::new(
|
if let Some(new_name) = self.name {
|
||||||
self.name.as_ref().unwrap_or(&vault.name),
|
vault.name = new_name;
|
||||||
self.username.as_ref().or(vault.username.as_ref()),
|
}
|
||||||
utils::user_password(self.password, "New vault password:")?
|
if self.password.is_some() {
|
||||||
.as_ref()
|
vault.password = utils::user_password(self.password, "New vault password:")?;
|
||||||
.or(vault.password.as_ref()),
|
}
|
||||||
self.service.as_ref().or(vault.service.as_ref()),
|
if let Some(new_username) = self.username {
|
||||||
self.note.as_ref().or(vault.note.as_ref()),
|
vault.username = Some(new_username);
|
||||||
);
|
}
|
||||||
|
if let Some(new_service) = self.service {
|
||||||
|
vault.service = Some(new_service);
|
||||||
|
}
|
||||||
|
if let Some(new_note) = self.note {
|
||||||
|
vault.note = Some(new_note);
|
||||||
|
}
|
||||||
|
utils::apply_custom_fields(&mut vault.custom_fields, self.custom_fields);
|
||||||
|
|
||||||
vault_manager.try_export()
|
vault_manager.try_export()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,11 +91,18 @@ impl LprsCommand for Edit {
|
||||||
&& self.password.is_none()
|
&& self.password.is_none()
|
||||||
&& self.service.is_none()
|
&& self.service.is_none()
|
||||||
&& self.note.is_none()
|
&& self.note.is_none()
|
||||||
|
&& self.custom_fields.is_empty()
|
||||||
{
|
{
|
||||||
return Err(LprsError::Other(
|
return Err(LprsError::Other(
|
||||||
"You must edit one option at least".to_owned(),
|
"You must edit one option at least".to_owned(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(duplicated_key) = utils::get_duplicated_field(&self.custom_fields) {
|
||||||
|
return Err(LprsError::Other(format!(
|
||||||
|
"Duplication error: The custom key `{duplicated_key}` is duplicate"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,8 @@ pub enum Error {
|
||||||
#[error("Invalid Vault Index Error: {0}")]
|
#[error("Invalid Vault Index Error: {0}")]
|
||||||
InvalidVaultIndex(String),
|
InvalidVaultIndex(String),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
ArgParse(String),
|
||||||
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
|
|
||||||
#[error("CLI error: {0}")]
|
#[error("CLI error: {0}")]
|
||||||
|
|
|
@ -21,6 +21,8 @@ use std::process::ExitCode;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use inquire::InquireError;
|
use inquire::InquireError;
|
||||||
|
|
||||||
|
/// A set of clap vaule parsers used to parse some CLI arguments
|
||||||
|
pub mod clap_parsers;
|
||||||
/// The main module of the lprs crate, contains the cli and the commands.
|
/// The main module of the lprs crate, contains the cli and the commands.
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
/// The errors module, contains the errors and the result type.
|
/// The errors module, contains the errors and the result type.
|
||||||
|
|
31
src/utils.rs
31
src/utils.rs
|
@ -14,6 +14,7 @@
|
||||||
// You should have received a copy of the GNU General Public License
|
// 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>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use inquire::{validator::Validation, Password, PasswordDisplayMode};
|
use inquire::{validator::Validation, Password, PasswordDisplayMode};
|
||||||
|
@ -174,3 +175,33 @@ pub fn lprs_version() -> LprsResult<Option<String>> {
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the duplicated field from the custom field (unprocessed fields)
|
||||||
|
pub fn get_duplicated_field(fields: &[(String, String)]) -> Option<&str> {
|
||||||
|
fields.iter().find_map(|(key, _)| {
|
||||||
|
if fields.iter().filter(|(k, _)| key == k).count() > 1 {
|
||||||
|
return Some(key.as_str());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the edited fields to the vault fields.
|
||||||
|
/// This will:
|
||||||
|
/// - Add the field if it's not in the fields map.
|
||||||
|
/// - Update the field if it's in the map.
|
||||||
|
/// - Remove the field if its value is empty string.
|
||||||
|
pub fn apply_custom_fields(
|
||||||
|
fields: &mut BTreeMap<String, String>,
|
||||||
|
edited_fields: Vec<(String, String)>,
|
||||||
|
) {
|
||||||
|
for (key, value) in edited_fields {
|
||||||
|
if fields.contains_key(&key) && value.is_empty() {
|
||||||
|
fields.remove(&key);
|
||||||
|
} else {
|
||||||
|
// The field is not there or its value not empty,
|
||||||
|
// so add it or update its value
|
||||||
|
fields.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -42,6 +42,12 @@ pub struct BitWardenFolder {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct BitWardenNameValue {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct BitWardenPassword {
|
pub struct BitWardenPassword {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
|
@ -49,6 +55,8 @@ pub struct BitWardenPassword {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub login: Option<BitWardenLoginData>,
|
pub login: Option<BitWardenLoginData>,
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fields: Vec<BitWardenNameValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The bitwarden password struct
|
/// The bitwarden password struct
|
||||||
|
@ -71,6 +79,11 @@ impl From<BitWardenPassword> for Vault {
|
||||||
.and_then(|p| p.first().map(|u| u.uri.clone()))
|
.and_then(|p| p.first().map(|u| u.uri.clone()))
|
||||||
}),
|
}),
|
||||||
value.notes,
|
value.notes,
|
||||||
|
value
|
||||||
|
.fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|nv| (nv.name, nv.value))
|
||||||
|
.collect(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,6 +101,11 @@ impl From<Vault> for BitWardenPassword {
|
||||||
.map(|s| vec![BitWardenUri { mt: None, uri: s }]),
|
.map(|s| vec![BitWardenUri { mt: None, uri: s }]),
|
||||||
}),
|
}),
|
||||||
notes: value.note,
|
notes: value.note,
|
||||||
|
fields: value
|
||||||
|
.custom_fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| BitWardenNameValue { name, value })
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU General Public License
|
// 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>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||||
|
|
||||||
use std::{fmt, fs, path::PathBuf};
|
use std::{collections::BTreeMap, fmt, fs, path::PathBuf};
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Parser, ValueEnum};
|
||||||
|
@ -58,6 +58,9 @@ pub struct Vault {
|
||||||
/// Add a note to the vault
|
/// Add a note to the vault
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
pub note: Option<String>,
|
pub note: Option<String>,
|
||||||
|
/// The vault custom fields
|
||||||
|
#[arg(skip)]
|
||||||
|
pub custom_fields: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The vaults manager
|
/// The vaults manager
|
||||||
|
@ -79,6 +82,7 @@ impl Vault {
|
||||||
password: Option<impl Into<String>>,
|
password: Option<impl Into<String>>,
|
||||||
service: Option<impl Into<String>>,
|
service: Option<impl Into<String>>,
|
||||||
note: Option<impl Into<String>>,
|
note: Option<impl Into<String>>,
|
||||||
|
custom_fields: BTreeMap<String, String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
|
@ -86,6 +90,7 @@ impl Vault {
|
||||||
password: password.map(Into::into),
|
password: password.map(Into::into),
|
||||||
service: service.map(Into::into),
|
service: service.map(Into::into),
|
||||||
note: note.map(Into::into),
|
note: note.map(Into::into),
|
||||||
|
custom_fields,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,9 +136,8 @@ impl Vaults {
|
||||||
///
|
///
|
||||||
/// Note: The returned string is `Vec<Vault>`
|
/// Note: The returned string is `Vec<Vault>`
|
||||||
pub fn json_export(&self, encryption_key: &[u8; 32]) -> LprsResult<String> {
|
pub fn json_export(&self, encryption_key: &[u8; 32]) -> LprsResult<String> {
|
||||||
let encrypt = |val: &str| {
|
let encrypt =
|
||||||
LprsResult::Ok(crate::BASE64.encode(cipher::encrypt(encryption_key, val.as_ref())))
|
|val: &str| crate::BASE64.encode(cipher::encrypt(encryption_key, val.as_ref()));
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::to_string(
|
serde_json::to_string(
|
||||||
&self
|
&self
|
||||||
|
@ -141,11 +145,15 @@ impl Vaults {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
LprsResult::Ok(Vault::new(
|
LprsResult::Ok(Vault::new(
|
||||||
encrypt(&v.name)?,
|
encrypt(&v.name),
|
||||||
v.username.as_ref().and_then(|u| encrypt(u).ok()),
|
v.username.as_ref().map(|u| encrypt(u)),
|
||||||
v.password.as_ref().and_then(|p| encrypt(p).ok()),
|
v.password.as_ref().map(|p| encrypt(p)),
|
||||||
v.service.as_ref().and_then(|s| encrypt(s).ok()),
|
v.service.as_ref().map(|s| encrypt(s)),
|
||||||
v.note.as_ref().and_then(|n| encrypt(n).ok()),
|
v.note.as_ref().map(|n| encrypt(n)),
|
||||||
|
v.custom_fields
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (encrypt(key), encrypt(value)))
|
||||||
|
.collect(),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect::<LprsResult<Vec<_>>>()?,
|
.collect::<LprsResult<Vec<_>>>()?,
|
||||||
|
@ -178,6 +186,10 @@ impl Vaults {
|
||||||
v.password.as_ref().and_then(|p| decrypt(p).ok()),
|
v.password.as_ref().and_then(|p| decrypt(p).ok()),
|
||||||
v.service.as_ref().and_then(|s| decrypt(s).ok()),
|
v.service.as_ref().and_then(|s| decrypt(s).ok()),
|
||||||
v.note.as_ref().and_then(|n| decrypt(n).ok()),
|
v.note.as_ref().and_then(|n| decrypt(n).ok()),
|
||||||
|
v.custom_fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| LprsResult::Ok((decrypt(&key)?, decrypt(&value)?)))
|
||||||
|
.collect::<LprsResult<_>>()?,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
@ -245,6 +257,9 @@ impl fmt::Display for Vault {
|
||||||
if let Some(ref note) = self.note {
|
if let Some(ref note) = self.note {
|
||||||
write!(f, "\nNote:\n{note}")?;
|
write!(f, "\nNote:\n{note}")?;
|
||||||
}
|
}
|
||||||
|
for (key, value) in &self.custom_fields {
|
||||||
|
write!(f, "\n{key}: {value}")?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue