First source commit
This commit is contained in:
parent
758bb79967
commit
018c8c63b2
14 changed files with 1720 additions and 0 deletions
1120
Cargo.lock
generated
Normal file
1120
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "passrs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["Awiteb <awiteb@hotmail.com>"]
|
||||
readme = "README.md"
|
||||
description = "Local CLI password manager"
|
||||
repository = "https://github.com/TheAwiteb/passrs"
|
||||
rust-version = "1.70.0"
|
||||
keywords = ["password", "manager", "CLI"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.21.5"
|
||||
clap = { version = "4.4.11", features = ["derive"] }
|
||||
comfy-table = "7.1.0"
|
||||
directories = "5.0.1"
|
||||
log = "0.4.20"
|
||||
passwords = { version = "3.1.16", features = ["common-password"] }
|
||||
pretty_env_logger = "0.5.0"
|
||||
regex = "1.10.2"
|
||||
scanpw = "1.0.0"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
serde_json = "1.0.108"
|
||||
sha256 = { version = "1.4.0", default-features = false }
|
||||
soft-aes = "0.1.0"
|
||||
thiserror = "1.0.51"
|
||||
url = { version = "2.5.0", features = ["serde"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true # Automatically strip symbols from the binary.
|
20
src/cli/add_command.rs
Normal file
20
src/cli/add_command.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
use clap::Args;
|
||||
|
||||
use crate::{
|
||||
password::{Password, Passwords},
|
||||
PassrsResult, RunCommand,
|
||||
};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Add {
|
||||
#[command(flatten)]
|
||||
password_info: Password,
|
||||
}
|
||||
|
||||
impl RunCommand for Add {
|
||||
fn run(&self, mut password_manager: Passwords) -> PassrsResult<()> {
|
||||
password_manager.add_password(self.password_info.clone());
|
||||
password_manager.try_export()
|
||||
}
|
||||
}
|
15
src/cli/clean_command.rs
Normal file
15
src/cli/clean_command.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use std::fs;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use crate::{password::Passwords, PassrsError, PassrsResult, RunCommand};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Clean {}
|
||||
|
||||
impl RunCommand for Clean {
|
||||
fn run(&self, password_manager: Passwords) -> PassrsResult<()> {
|
||||
fs::write(password_manager.passwords_file, "[]").map_err(PassrsError::Io)
|
||||
}
|
||||
}
|
120
src/cli/list_command.rs
Normal file
120
src/cli/list_command.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use clap::Args;
|
||||
use comfy_table::Table;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{password::Passwords, PassrsError, PassrsResult, RunCommand};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct List {
|
||||
/// Show the clean password
|
||||
#[arg(short = 'p', long)]
|
||||
unhide_password: bool,
|
||||
/// Show the service of the password and search in it if you search
|
||||
#[arg(short = 's', long)]
|
||||
with_service: bool,
|
||||
/// Show the note of the password and search in it if you search
|
||||
#[arg(short = 'n', long)]
|
||||
with_note: bool,
|
||||
|
||||
/// Return the password with spesifc index
|
||||
#[arg(short, long, value_name = "INDEX")]
|
||||
get: Option<usize>,
|
||||
/// Search and display only matching passwords.
|
||||
///
|
||||
/// The name and username will be searched. And service and note if included
|
||||
#[arg(short = 'e', long, value_name = "TEXT")]
|
||||
search: Option<String>,
|
||||
/// Enable regex in the search
|
||||
#[arg(short, long)]
|
||||
regex: bool,
|
||||
}
|
||||
|
||||
impl RunCommand for List {
|
||||
fn run(&self, password_manager: Passwords) -> PassrsResult<()> {
|
||||
if password_manager.passwords.is_empty() {
|
||||
println!("Looks like there is no passwords to list")
|
||||
} else {
|
||||
if self.get.is_some() && self.search.is_some() {
|
||||
return Err(PassrsError::ArgsConflict(
|
||||
"You cannot use `--get` arg with `--search` arg".to_owned(),
|
||||
));
|
||||
}
|
||||
if self.regex && self.search.is_none() {
|
||||
return Err(PassrsError::ArgsConflict(
|
||||
"You cannot use `--regex` without `--search` arg".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut table = Table::new();
|
||||
let mut header = vec!["Index", "Name", "Username", "Password"];
|
||||
if self.with_service {
|
||||
header.push("Service");
|
||||
}
|
||||
if self.with_note {
|
||||
header.push("Note");
|
||||
}
|
||||
let re = Regex::new(self.search.as_deref().unwrap_or("."))?;
|
||||
|
||||
table.set_header(header);
|
||||
let passwords = password_manager
|
||||
.passwords
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, pass)| {
|
||||
if let Some(index) = self.get {
|
||||
return (idx + 1) == index;
|
||||
}
|
||||
if let Some(ref pattern) = self.search {
|
||||
if self.regex {
|
||||
return re.is_match(&pass.name)
|
||||
|| re.is_match(&pass.username)
|
||||
|| (self.with_service
|
||||
&& pass.service.as_ref().is_some_and(|s| re.is_match(s)))
|
||||
|| (self.with_note
|
||||
&& pass.note.as_ref().is_some_and(|n| re.is_match(n)));
|
||||
} else {
|
||||
let pattern = pattern.to_lowercase();
|
||||
return pass.name.to_lowercase().contains(&pattern)
|
||||
|| pass.username.to_lowercase().contains(&pattern)
|
||||
|| (self.with_service
|
||||
&& pass
|
||||
.service
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.to_lowercase().contains(&pattern)))
|
||||
|| (self.with_note
|
||||
&& pass
|
||||
.note
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.to_lowercase().contains(&pattern)));
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
for (idx, password) in passwords {
|
||||
let hide_password = "*".repeat(password.password.chars().count());
|
||||
let idx = (idx + 1).to_string();
|
||||
let mut row = vec![
|
||||
idx.as_str(),
|
||||
password.name.as_str(),
|
||||
password.username.as_str(),
|
||||
if self.unhide_password {
|
||||
password.password.as_str()
|
||||
} else {
|
||||
hide_password.as_str()
|
||||
},
|
||||
];
|
||||
if self.with_service {
|
||||
row.push(password.service.as_deref().unwrap_or("Not Set"))
|
||||
}
|
||||
if self.with_note {
|
||||
row.push(password.note.as_deref().unwrap_or("Not Set"))
|
||||
}
|
||||
table.add_row(row);
|
||||
}
|
||||
println!("{table}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
73
src/cli/mod.rs
Normal file
73
src/cli/mod.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::{
|
||||
password::{self, Passwords},
|
||||
PassrsError, PassrsResult, RunCommand,
|
||||
};
|
||||
|
||||
pub mod add_command;
|
||||
pub mod clean_command;
|
||||
pub mod list_command;
|
||||
|
||||
crate::create_commands!(
|
||||
enum Commands
|
||||
"Add new password", Add => add_command::Add
|
||||
"List your password and search", List => list_command::List
|
||||
"Clean the password file", Clean => clean_command::Clean
|
||||
// TODO: Edit command
|
||||
// TODO: Delete command
|
||||
// TODO: Export command
|
||||
// TODO: Import command
|
||||
);
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// The passwords json file, default: $HOME/.local/share/passrs/passwords.json
|
||||
#[arg(short, long)]
|
||||
passwords_file: Option<PathBuf>,
|
||||
|
||||
// TODO: verbose flag
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Run the cli
|
||||
pub fn run(self) -> PassrsResult<()> {
|
||||
let passwords_file = if let Some(ref path) = self.passwords_file {
|
||||
path.clone()
|
||||
} else {
|
||||
crate::utils::passwords_file()?
|
||||
};
|
||||
log::debug!(
|
||||
"Getting password file: {}",
|
||||
passwords_file.to_string_lossy()
|
||||
);
|
||||
let password = scanpw::scanpw!("Master Password: ");
|
||||
|
||||
if password::is_new_password_file(&passwords_file)? {
|
||||
let analyzed = passwords::analyzer::analyze(&password);
|
||||
if analyzed.length() < 15 {
|
||||
return Err(PassrsError::WeakPassword(
|
||||
"The password length must be beggier then 15".to_owned(),
|
||||
));
|
||||
} else if passwords::scorer::score(&analyzed) < 80.0 {
|
||||
return Err(PassrsError::WeakPassword(
|
||||
"Your password is not stronge enough".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let master_password = sha256::digest(password);
|
||||
let password_manager = Passwords::try_reload(
|
||||
passwords_file,
|
||||
master_password.into_bytes().into_iter().take(32).collect(),
|
||||
)?;
|
||||
self.command.run(password_manager)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
41
src/errors.rs
Normal file
41
src/errors.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use std::{process::ExitCode, string::FromUtf8Error};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("Invalid Json Path Error: {0}")]
|
||||
InvalidJsonPath(String),
|
||||
#[error("Encryption Error: {0}")]
|
||||
Encryption(String),
|
||||
#[error("Decryption Error: {0}")]
|
||||
Decryption(String),
|
||||
#[error(
|
||||
"Wrong Master Password Error: Wrong password or you may have played with the password file"
|
||||
)]
|
||||
WrongMasterPassword,
|
||||
#[error("Weak Password Error: {0}")]
|
||||
WeakPassword(String),
|
||||
#[error("Args Conflict Error: {0}")]
|
||||
ArgsConflict(String),
|
||||
|
||||
#[error("Invalid Regex: {0}")]
|
||||
InvalidRegex(#[from] regex::Error),
|
||||
#[error("UTF8 Error: {0}")]
|
||||
Utf8(#[from] FromUtf8Error),
|
||||
#[error("Base64 Decode Error: {0}")]
|
||||
BaseDecodeError(#[from] base64::DecodeError),
|
||||
#[error("Json Error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Project Folder Error: {0}")]
|
||||
ProjectDir(String),
|
||||
#[error("IO Error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Return the exit code of the error
|
||||
pub fn exit_code(&self) -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
59
src/macros.rs
Normal file
59
src/macros.rs
Normal file
|
@ -0,0 +1,59 @@
|
|||
/// Creates commands macro, to create the `Commands` enum and impl `RunCommand` to it.
|
||||
///
|
||||
/// ### Notes:
|
||||
/// The `$command` must impl `RunCommand` trait
|
||||
///
|
||||
/// ### Example:
|
||||
/// ```rust
|
||||
/// create_commands!(
|
||||
/// enum TestCommands
|
||||
/// "Test command", Test => TestArgs
|
||||
/// "Do something", Some => SomeArgs
|
||||
/// );
|
||||
/// ```
|
||||
/// #### Output
|
||||
/// ```rust
|
||||
/// ///The passrs commands
|
||||
/// pub enum TestCommands {
|
||||
/// ///Test command
|
||||
/// Test(TestArgs),
|
||||
/// ///Do something
|
||||
/// Some(SomeArgs),
|
||||
/// }
|
||||
///
|
||||
/// impl crate::RunCommand for TestCommands {
|
||||
/// fn run(
|
||||
/// &self,
|
||||
/// password_manager: crate::password::Passwords,
|
||||
/// ) -> crate::PassrsResult<()> {
|
||||
/// match self {
|
||||
/// Self::Test(command) => command.run(password_manager),
|
||||
/// Self::Some(command) => command.run(password_manager),
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! create_commands {
|
||||
(enum $enum_name: ident $($doc:tt, $varint: ident => $command: ty)+) => {
|
||||
#[doc = "The passrs commands"]
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub enum $enum_name {
|
||||
$(
|
||||
#[doc = $doc]
|
||||
$varint($command),
|
||||
)+
|
||||
}
|
||||
|
||||
#[automatically_derived]
|
||||
impl $crate::RunCommand for $enum_name{
|
||||
fn run(&self, password_manager: $crate::password::Passwords) -> $crate::PassrsResult<()> {
|
||||
match self {
|
||||
$(
|
||||
Self::$varint(command) => command.run(password_manager),
|
||||
)+
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
32
src/main.rs
Normal file
32
src/main.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use std::process::ExitCode;
|
||||
|
||||
use base64::{
|
||||
alphabet,
|
||||
engine::{general_purpose::PAD, GeneralPurpose},
|
||||
};
|
||||
use clap::Parser;
|
||||
|
||||
pub mod cli;
|
||||
pub mod errors;
|
||||
pub mod password;
|
||||
pub mod utils;
|
||||
|
||||
mod macros;
|
||||
mod traits;
|
||||
|
||||
pub use {macros::*, traits::*};
|
||||
|
||||
pub use errors::{Error as PassrsError, Result as PassrsResult};
|
||||
|
||||
pub const STANDARDBASE: GeneralPurpose = GeneralPurpose::new(&alphabet::STANDARD, PAD);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
pretty_env_logger::init();
|
||||
|
||||
if let Err(err) = cli::Cli::parse().run() {
|
||||
eprintln!("{err}");
|
||||
err.exit_code()
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
32
src/password/cipher.rs
Normal file
32
src/password/cipher.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use base64::Engine;
|
||||
use soft_aes::aes::{aes_dec_ecb, aes_enc_ecb};
|
||||
|
||||
use crate::{PassrsError, PassrsResult};
|
||||
|
||||
/// Encrypt the string with AEC ECB
|
||||
pub fn encrypt(master_password: &[u8], data: &str) -> PassrsResult<String> {
|
||||
let padding = Some("PKCS7");
|
||||
|
||||
aes_enc_ecb(data.as_bytes(), master_password, padding)
|
||||
.map(|d| crate::STANDARDBASE.encode(d))
|
||||
.map_err(|err| PassrsError::Encryption(err.to_string()))
|
||||
}
|
||||
|
||||
/// Decrypt the string with AEC ECB
|
||||
pub fn decrypt(master_password: &[u8], data: &str) -> PassrsResult<String> {
|
||||
let padding = Some("PKCS7");
|
||||
|
||||
aes_dec_ecb(
|
||||
crate::STANDARDBASE.decode(data)?.as_slice(),
|
||||
master_password,
|
||||
padding,
|
||||
)
|
||||
.map_err(|err| {
|
||||
if err.to_string().contains("Invalid padding") {
|
||||
PassrsError::WrongMasterPassword
|
||||
} else {
|
||||
PassrsError::Decryption(err.to_string())
|
||||
}
|
||||
})
|
||||
.map(|d| String::from_utf8(d).map_err(PassrsError::Utf8))?
|
||||
}
|
129
src/password/mod.rs
Normal file
129
src/password/mod.rs
Normal file
|
@ -0,0 +1,129 @@
|
|||
use std::{fs, path::PathBuf};
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PassrsError, PassrsResult};
|
||||
|
||||
pub mod cipher;
|
||||
mod validator;
|
||||
|
||||
pub use validator::*;
|
||||
|
||||
/// The passwords manager
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Passwords {
|
||||
/// Hash of the master password
|
||||
#[serde(skip)]
|
||||
pub master_password: Vec<u8>,
|
||||
/// The json passwords file
|
||||
#[serde(skip)]
|
||||
pub passwords_file: PathBuf,
|
||||
/// The passwords
|
||||
pub passwords: Vec<Password>,
|
||||
}
|
||||
|
||||
/// The password struct
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Parser)]
|
||||
pub struct Password {
|
||||
/// The name of the password
|
||||
#[arg(short, long)]
|
||||
pub name: String,
|
||||
/// The username
|
||||
#[arg(short, long)]
|
||||
pub username: String,
|
||||
/// The password
|
||||
#[arg(short, long)]
|
||||
pub password: String,
|
||||
/// The service name. e.g the website url
|
||||
#[arg(short, long)]
|
||||
pub service: Option<String>,
|
||||
/// The note of the password
|
||||
#[arg(short = 'o', long)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl Password {
|
||||
/// Encrypt the password data
|
||||
pub fn encrypt(self, master_password: &[u8]) -> PassrsResult<Self> {
|
||||
Ok(Self {
|
||||
name: cipher::encrypt(master_password, &self.name)?,
|
||||
username: cipher::encrypt(master_password, &self.username)?,
|
||||
password: cipher::encrypt(master_password, &self.password)?,
|
||||
service: self
|
||||
.service
|
||||
.map(|url| cipher::encrypt(master_password, &url))
|
||||
.transpose()?,
|
||||
note: self
|
||||
.note
|
||||
.map(|note| cipher::encrypt(master_password, ¬e))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt the password data
|
||||
pub fn decrypt(self, master_password: &[u8]) -> PassrsResult<Self> {
|
||||
Ok(Self {
|
||||
name: cipher::decrypt(master_password, &self.name)?,
|
||||
username: cipher::decrypt(master_password, &self.username)?,
|
||||
password: cipher::decrypt(master_password, &self.password)?,
|
||||
service: self
|
||||
.service
|
||||
.map(|url| cipher::decrypt(master_password, &url))
|
||||
.transpose()?,
|
||||
note: self
|
||||
.note
|
||||
.map(|note| cipher::decrypt(master_password, ¬e))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Passwords {
|
||||
/// Create new Passwords instnce
|
||||
pub fn new(
|
||||
master_password: Vec<u8>,
|
||||
passwords_file: PathBuf,
|
||||
passwords: Vec<Password>,
|
||||
) -> Self {
|
||||
Self {
|
||||
master_password,
|
||||
passwords_file,
|
||||
passwords,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt the passwords
|
||||
fn encrypt(self) -> PassrsResult<Self> {
|
||||
Ok(Self {
|
||||
passwords: self
|
||||
.passwords
|
||||
.into_iter()
|
||||
.map(|p| p.encrypt(&self.master_password))
|
||||
.collect::<PassrsResult<Vec<Password>>>()?,
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// Reload the passwords from the file
|
||||
pub fn try_reload(passwords_file: PathBuf, master_password: Vec<u8>) -> PassrsResult<Self> {
|
||||
let passwords =
|
||||
serde_json::from_str::<Vec<Password>>(&fs::read_to_string(&passwords_file)?)?
|
||||
.into_iter()
|
||||
.map(|p| p.decrypt(master_password.as_slice()))
|
||||
.collect::<PassrsResult<Vec<Password>>>()?;
|
||||
|
||||
Ok(Self::new(master_password, passwords_file, passwords))
|
||||
}
|
||||
|
||||
/// Export the passwords to the file
|
||||
pub fn try_export(self) -> PassrsResult<()> {
|
||||
let path = self.passwords_file.to_path_buf();
|
||||
fs::write(path, serde_json::to_string(&self.encrypt()?.passwords)?).map_err(PassrsError::Io)
|
||||
}
|
||||
|
||||
/// Add new password
|
||||
pub fn add_password(&mut self, password: Password) {
|
||||
self.passwords.push(password)
|
||||
}
|
||||
}
|
19
src/password/validator.rs
Normal file
19
src/password/validator.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use std::{fs, path::Path};
|
||||
|
||||
use crate::PassrsResult;
|
||||
|
||||
use super::Password;
|
||||
|
||||
/// Return if the password file new file or not
|
||||
pub fn is_new_password_file(path: &Path) -> PassrsResult<bool> {
|
||||
if path.exists() {
|
||||
let file_content = fs::read_to_string(path)?;
|
||||
if !file_content.is_empty()
|
||||
&& file_content.trim() != "[]"
|
||||
&& serde_json::from_str::<Vec<Password>>(&file_content).is_ok()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
6
src/traits.rs
Normal file
6
src/traits.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
use crate::{password::Passwords, PassrsResult};
|
||||
|
||||
/// Trait to run the command
|
||||
pub trait RunCommand {
|
||||
fn run(&self, password_manager: Passwords) -> PassrsResult<()>;
|
||||
}
|
22
src/utils.rs
Normal file
22
src/utils.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use std::{fs, path::PathBuf};
|
||||
|
||||
use crate::{PassrsError, PassrsResult};
|
||||
|
||||
/// Return the default passwords json file
|
||||
pub fn passwords_file() -> PassrsResult<PathBuf> {
|
||||
if let Some(path) = directories::ProjectDirs::from("", "", "passrs")
|
||||
.map(|d| d.data_local_dir().to_path_buf().join("passwords.json"))
|
||||
{
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if !path.exists() {
|
||||
fs::write(&path, "[]")?;
|
||||
}
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(PassrsError::ProjectDir(
|
||||
"Can't extract the project_dir from this OS".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue