Compare commits

..

No commits in common. 'main' and 'basic-arg-example-#6' have entirely different histories.

  1. 5
      Cargo.toml
  2. 51
      README.md
  3. 69
      src/editor.rs
  4. 95
      src/encoder.rs
  5. 43
      src/git.rs
  6. 327
      src/main.rs
  7. 24
      src/paths.rs
  8. 253
      src/storage.rs

5
Cargo.toml

@ -6,9 +6,4 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
aes-gcm = "0.10.3"
clap = { version = "4.5.16", features = ["derive"] }
hex = "0.4.3"
rpassword = "7.3.1"
tempfile = "3.12.0"

51
README.md

@ -1,50 +1,3 @@
# MPS
A small tool for storing passwords locally with git sync
## Installation
1. Clone MPS repo:
```
git clone git@code.corpglory.net:corpglory/mps.git
```
2. Build MPS
```
cd mps
cargo build --release
```
3. Run init
```
mps init
```
4. Add your storage under git
```bash
cd storage
git init
git checkout -b main
git add db.mps
git commit -m "init mps"
git remote add origin <your_git_storage_url>
git push -u origin main
```
### Intilization with exisitng storage db repo
1. Create empty repository for storing your passwords on gitlab or somewhere you prefer
2. Clone to your home folder `<mps_path>/storage`: `git clone <your_git_storage_url> storage`
#### Exporting `MPS_HOME`
You can export variable `$MPS_HOME` to init storage for example in your home directory:
`export MPS_HOME="/home/<your_username>/.mps"` in your `~/.zshenv`
now `mps` will try to get storage from `$MPS_HOME/storage`
#### Add your mps build to PATH in your ~/.zshenv
```
export PATH=$PATH:"<mps_path>/target/release"
```
# mps
A small tool for storing passwords locally with git sync

69
src/editor.rs

@ -1,69 +0,0 @@
use std::env;
use std::fs::read_to_string;
use std::process::Command;
use tempfile::NamedTempFile;
use std::io::{self, Write};
const ENV_MPS_EDITOR: &str = "MPS_EDITOR";
const DEFAULT_EDITOR: &str = "nano";
// Use with content "" (empty string) for adding new item
pub fn open_to_edit(content: &String) -> io::Result<String> {
// Create a temporary file
let mut temp_file = NamedTempFile::new()?;
// Write content
write!(temp_file, "{}", content)?;
// Get the user's preferred editor from the $EDITOR environment variable
// Default is 'nano'
let editor = env::var(ENV_MPS_EDITOR).unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
// Get the path of the temp file
let file_path = temp_file.path().to_str().unwrap().to_string();
// Open the file in the external editor
Command::new(editor)
.arg(&file_path)
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("Failed to launch editor: {}", e)
))?;
// Read the file content after editing
let mut edited_content = read_to_string(&file_path)?;
// Remove only one trailing newline if it exists
// because editor like vim or nano adds to the end new line
if edited_content.ends_with('\n') {
edited_content.pop();
}
// Print the edited content
Ok(edited_content)
}
pub fn open_to_show(content: &String) -> io::Result<()> {
let mut temp_file = NamedTempFile::new()?;
// Write the content to the file
write!(temp_file, "{}", content)?;
// Default is 'nano'
let editor = env::var(ENV_MPS_EDITOR).unwrap_or_else(|_| DEFAULT_EDITOR.to_string());
let file_path = temp_file.path().to_str().unwrap().to_string();
// Open the file in the external editor
Command::new(editor)
.arg(&file_path)
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("Failed to launch editor: {}", e)
))?;
Ok(())
}

95
src/encoder.rs

@ -1,95 +0,0 @@
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce
};
use std::io;
static PASSWORD_TEST: &str = "MyPasswordStorage"; // will be added with nonce for storing each time
pub struct Encoder {
// will be stored with padding 32 bytes
passphrase: String
}
impl Encoder {
pub fn from(passphrase: &String) -> Encoder {
// TODO: throw error if password longer that 32 bytes
let padded_passphrase = Encoder::get_passhrase_with_padding(passphrase);
Encoder { passphrase: padded_passphrase }
}
fn get_passhrase_with_padding(passphrase: &String) -> String {
let mut result = passphrase.clone();
while result.len() < 32 {
// use '-' for padding, can be anything else
result.push('-');
}
result
}
// TODO: error type
pub fn encrypt(&self, plain_text: &String) -> io::Result<String> {
let key = Key::<Aes256Gcm>::from_slice(self.passphrase.as_bytes());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let cipher = Aes256Gcm::new(key);
// TODO: mar error inted of expect
let ciphered_data = cipher.encrypt(&nonce, plain_text.as_bytes())
.map_err(|_| io::Error::new(
io::ErrorKind::Other,
"Failed to encrypt"
))?;
// combining nonce and encrypted data together
// for storage purpose
let mut encrypted_data: Vec<u8> = nonce.to_vec();
encrypted_data.extend_from_slice(&ciphered_data);
Ok(hex::encode(encrypted_data))
}
// TODO: review error type
pub fn decrypt(&self, encrypted_data: String) -> io::Result<String> {
let encrypted_data = hex::decode(encrypted_data)
.map_err(|_| io::Error::new(
io::ErrorKind::Other,
"failed to decode hex string into vec"
))?;
let key = Key::<Aes256Gcm>::from_slice(self.passphrase.as_bytes());
let (nonce_arr, ciphered_data) = encrypted_data.split_at(12);
let nonce = Nonce::from_slice(nonce_arr);
let cipher = Aes256Gcm::new(key);
let plaintext = cipher.decrypt(nonce, ciphered_data)
.map_err(|_| io::Error::new(
io::ErrorKind::InvalidData,
"failed to decrypt data"
))?;
let result = String::from_utf8(plaintext)
.map_err(|_| io::Error::new(
io::ErrorKind::InvalidData,
"failed to convert vector of bytes to string"
))?;
Ok(result)
}
pub fn test_encoded_passphrase(&self, passphrase_encrypted: String) -> io::Result<bool> {
// TODO: better way to check error
let decrypted = match self.decrypt(passphrase_encrypted) {
Ok(decrypted) => decrypted,
Err(_) => return Ok(false)
};
Ok(PASSWORD_TEST == decrypted)
}
pub fn get_encoded_test_passphrase(&self) -> io::Result<String> {
self.encrypt(&PASSWORD_TEST.to_string())
}
}

43
src/git.rs

@ -1,43 +0,0 @@
use crate::paths;
use std::process::Command;
use std::io;
pub struct Git {
}
impl Git {
pub fn sync() -> io::Result<()> {
let sp = paths::get_storage_path()?;
Command::new("git")
.arg("add")
.arg(paths::PATH_DB)
.current_dir(&sp)
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("Failed to execute git add: {}", e)
))?;
Command::new("git")
.arg("commit")
.arg("-m")
.arg("\"sync\"")
.current_dir(&sp)
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("Failed to execute git commit: {}", e)
))?;
Command::new("git")
.arg("push")
.current_dir(&sp)
.status()
.map_err(|e| io::Error::new(
io::ErrorKind::Other,
format!("Failed to execute git push: {}", e)
))?;
Ok(())
}
}

327
src/main.rs

@ -1,24 +1,7 @@
mod paths;
mod encoder;
mod storage;
mod editor;
mod git;
use storage::{Storage, Item};
use clap::{Parser, Subcommand, ArgGroup};
use rpassword;
use std::io::{self, Write};
use std::process;
const VERSION: &str = "0.0.1";
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mps", version = VERSION, about = "MyPasswordStorage: Tool for storing your passwords locally with git synchronization")]
#[command(name = "mps", version = "0.0.1", about = "MyPasswordStorage: Tool for storing your passwords locally with synchronization with git.")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
@ -26,317 +9,29 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Initialization of storage and config, use this in first time of usage of mps
/// Initialisation of storage and config, use this in first time of usage of mps
Init,
/// Lists all ids stored in db
List,
/// Show content of an item
Show(ItemIdArgs),
/// Adds new item with unique id to the storage
/// Adds new item with unique id to the db
Add {
#[arg(value_name="item_id")]
id: String
},
/// Edit item content
Edit(ItemIdArgs),
/// Delete item
Delete(ItemIdArgs),
/// Set new passphrase
Password
}
#[derive(Parser)]
#[command(group(
ArgGroup::new("item")
.required(true)
.args(&["id", "number"])
))]
struct ItemIdArgs {
/// Item id
#[arg(required=false)]
id: Option<String>,
#[arg(short='n', long, value_name="number")]
number: Option<u32>
}
enum PROMPT {
YES,
NO
}
fn get_prompt(question: &str) -> io::Result<PROMPT> {
print!("{} [Y/n] ", question);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
match input.as_str() {
"y" => Ok(PROMPT::YES),
"yes" => Ok(PROMPT::YES),
"n" => Ok(PROMPT::NO),
"no" => Ok(PROMPT::NO),
_ => Ok(PROMPT::YES),
}
}
struct MPS {
storage: Option<Storage>
}
impl MPS {
pub fn new() -> MPS {
MPS { storage: None }
}
fn print_init_hint() {
println!("1. Run `mps init`");
println!("2. Init `storage` under git:");
println!(" cd storage");
println!(" git init");
input: String
}
fn prompt_new_password() -> io::Result<String> {
print!("Enter passphrase: ");
io::stdout().flush()?;
let ps = rpassword::read_password()?;
if ps.len() < 8 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Passphrase must be longer that 8 letters"));
}
if ps.len() > 32 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Passphrase must not be longer than 32 letters"));
}
print!("Enter same passphrase again: ");
io::stdout().flush()?;
let ps2 = rpassword::read_password()?;
if ps != ps2 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "Passphrases must be equal"));
}
Ok(ps)
}
fn read_new_password() -> io::Result<String> {
let passphrase;
loop {
match MPS::prompt_new_password() {
Ok(ps) => {
passphrase = ps;
break;
},
Err(e) => {
println!("{}", e);
continue
}
}
}
Ok(passphrase)
}
fn init() -> io::Result<()> {
if Storage::is_inited()? {
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "Reinitialization attempted"));
}
let passphrase = MPS::read_new_password()?;
Storage::init(passphrase)
}
pub fn login(&mut self) -> io::Result<()> {
if self.storage.is_some() {
return Ok(());
}
print!("Enter passphrase for storage: ");
io::stdout().flush()?;
let passphrase = rpassword::read_password()?;
print!("\x1B[1A\x1B[2K"); // Move cursor up one line and clear that line
io::stdout().flush()?;
// storage will return error if passphrase is incorrect
self.storage = Some(Storage::from_db(passphrase)?);
Ok(())
}
pub fn password(&mut self) -> io::Result<()> {
self.login()?;
let st = self.storage.as_ref().unwrap();
let passphrase = MPS::read_new_password()?;
let new_st = st.new_from_passphrase(&passphrase);
new_st.dump()
}
fn list(&mut self) -> io:: Result<()> {
self.login()?;
let ids = self.storage.as_ref().unwrap().ids();
let mut counter = 1;
if ids.len() == 0 {
println!("No items");
} else {
for id in ids {
println!("[{}] {}", counter, id);
counter += 1;
}
}
Ok(())
}
/// Show item. Id must exists
fn show(&mut self, id: &String) -> io::Result<()> {
self.login()?;
let st = self.storage.as_ref().unwrap();
let item = st.get(id);
editor::open_to_show(&item.content)
}
fn add(&mut self, id: &String) -> io::Result<()> {
self.login()?;
let st = self.storage.as_mut().unwrap();
if st.contains(id) {
let question = format!("Item [{}] exist. Do you want to edit it instead?", id);
match get_prompt(&question)? {
PROMPT::YES => {
self.edit(id)?;
return Ok(());
},
PROMPT::NO => return Ok(()),
}
}
// set empty string because there is no content yet
let content = editor::open_to_edit(&String::from(""))?;
st.add(Item::from(id.clone(), content));
st.dump()
}
/// Edit item, id need not to exist
fn edit(&mut self, id: &String) -> io::Result<()> {
self.login()?;
let st = self.storage.as_mut().unwrap();
if !st.contains(id) {
let question = format!("Item [{}] exist. Do you want to add it instead?", id);
match get_prompt(&question)? {
PROMPT::YES => {
self.add(id)?;
return Ok(());
}
PROMPT::NO => return Ok(())
}
}
let mut item = (*st.get(id)).clone();
let new_content = editor::open_to_edit(&item.content)?;
item.content = new_content;
st.update(item);
st.dump()
}
// Delete item by id, is must exist
fn delete(&mut self, id: &String) -> io::Result<()> {
self.login()?;
let st = self.storage.as_mut().unwrap();
st.remove(id);
st.dump()
}
/// Resolve id by ItemArgs.
/// # Arguments
/// * `args` - arguments to parse
/// * `check` - check that id existing
fn item_id_by_item_id_args(&mut self, args: &ItemIdArgs, check: bool) -> io::Result<String> {
self.login()?;
let st = self.storage.as_mut().unwrap();
let mut item_id: String = "NOT_INITIALIZED".to_string();
if let Some(id) = &args.id {
item_id = id.clone();
if check && !st.contains(&id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("No such item {}", id)
));
}
}
if let Some(number) = &args.number {
item_id = st.get_id_by_number(*number)?;
// we can guarantee that id exists because we take it by id
}
if args.id.is_none() && args.number.is_none() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Bag arguments")
));
}
Ok(item_id.clone())
}
}
fn run_command() -> io::Result<()> {
fn main() {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Init) => {
MPS::init()?;
}
Some(Commands::List) => {
let mut mps = MPS::new();
mps.list()?;
}
Some(Commands::Show(args)) => {
let mut mps = MPS::new();
let id = mps.item_id_by_item_id_args(args, true)?;
mps.show(&id)?;
println!("Initializing storage and config.");
}
Some(Commands::Add { id }) => {
let mut mps = MPS::new();
mps.add(id)?;
}
Some(Commands::Edit(args)) => {
let mut mps = MPS::new();
let id = mps.item_id_by_item_id_args(args, false)?;
mps.edit(&id)?;
}
Some(Commands::Delete(args)) => {
let mut mps = MPS::new();
let id = mps.item_id_by_item_id_args(args, true)?;
mps.delete(&id)?;
}
Some(Commands::Password) => {
let mut mps = MPS::new();
mps.password()?;
Some(Commands::Add{input}) => {
println!("about to add new item");
}
None => {
match Storage::check_installed() {
Ok(()) => (),
Err(e) => {
println!("{}", e);
MPS::print_init_hint();
return Ok(());
}
}
if !Storage::is_inited()? {
match get_prompt("Do you want to init storage db?")? {
PROMPT::YES => MPS::init()?,
PROMPT::NO => return Ok(()),
}
} else {
let mut mps = MPS::new();
mps.list()?
}
println!("Will be here init or list if storage inited");
}
}
Ok(())
}
fn main() {
match run_command() {
Ok(()) => return,
Err(e) => {
println!("{}", e);
process::exit(2); // TODO: better codes for different errors
}
}
}

24
src/paths.rs

@ -1,24 +0,0 @@
use std::env;
use std::io;
pub static ENV_MPS_HOME: &str = "MPS_HOME";
pub static PATH_STORAGE: &str = "storage"; // should be under git
pub static PATH_DB: &str = "db.mps";
pub fn get_storage_path() -> io::Result<String> {
let result = match env::var(ENV_MPS_HOME) {
Ok(mps_home) => format!("{}/{}", mps_home, PATH_STORAGE),
Err(_) => PATH_STORAGE.to_string()
};
Ok(result)
}
pub fn get_db_path() -> io::Result<String> {
let st = get_storage_path()?;
let result = format!("{}/{}", st, PATH_DB);
Ok(result)
}

253
src/storage.rs

@ -1,253 +0,0 @@
use crate::paths;
use crate::encoder;
use crate::git;
use encoder::Encoder;
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use std::fs;
use std::path::Path;
use std::io::{self, Write, BufRead};
use std::fmt;
use std::cmp::{PartialEq, Ordering};
#[derive(Clone)]
pub struct Item {
pub id: String,
pub content: String
}
impl Item {
pub fn from(s: String, c: String) -> Item {
Item { id: s, content: c }
}
// used only to search in HashSet
pub fn from_empty(s: String) -> Item {
Item { id: s, content: String::from("") }
}
}
impl PartialEq for Item {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for Item {}
impl Hash for Item {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl fmt::Display for Item {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "{}", self.id)?;
writeln!(f, "---------")?;
writeln!(f, "{}", self.content)
}
}
impl PartialOrd for Item {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.id.partial_cmp(&other.id)
}
}
impl Ord for Item {
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
pub struct Storage {
items: HashSet::<Item>,
encoder: Encoder
}
impl Storage {
pub fn new(passphrase: String) -> Storage {
Storage {
items: HashSet::<Item>::new(),
encoder: Encoder::from(&passphrase)
}
}
pub fn from_db(passphrase: String) -> io::Result<Storage> {
if !Storage::is_inited()? {
return Err(io::Error::new(
io::ErrorKind::Other,
"Storage is not initialized"
));
}
let encoder = Encoder::from(&passphrase);
let file = fs::File::open(paths::get_db_path()?)?;
let reader = io::BufReader::new(file);
let mut items = HashSet::<Item>::new();
let mut id: Option<String> = None;
let mut lines = reader.lines();
let passtest = match lines.next() {
Some(line) => line?,
None => return Err(
io::Error::new(io::ErrorKind::InvalidData,
"Bad storage db format: no passphrase in the beginnning"
)),
};
// TODO: only in debug mode
//println!("passphrase ok");
if !encoder.test_encoded_passphrase(passtest)? {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Wrong passphrase"));
}
for line in lines {
match line {
Ok(line) => {
if id.is_none() {
let line = encoder.decrypt(line)?;
// TODO: only in debug mode
//println!("{}", line);
id = Some(line);
} else {
let content = encoder.decrypt(line)?;
// TODO: only in debug
//println!("{}", content);
items.insert(Item::from(id.unwrap(), content));
id = None;
}
},
Err(e) => {
eprintln!("Error reading line, {}", e);
}
}
}
Ok(Storage {
items,
encoder
})
}
pub fn init(passphrase: String) -> io::Result<()> {
//Storage::check_installed()?;
let sp = paths::get_storage_path()?;
fs::create_dir(sp)?;
let st = Storage::new(passphrase);
st.dump_db()?;
println!("Storage db created");
Ok(())
}
pub fn check_installed() -> io::Result<()> {
let sp = paths::get_storage_path()?;
let storage_path = Path::new(&sp);
// Check if the folder exists and is a directory
if !storage_path.exists() || !storage_path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{} does not exist or not a dir", sp)
));
}
let git_path = storage_path.join(".git");
if !git_path.exists() || !git_path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{} not under git", sp)
));
}
Ok(())
}
pub fn is_inited() -> io::Result<bool> {
//Storage::check_installed()?;
let db = paths::get_db_path()?;
let db_path = Path::new(&db);
Ok(db_path.exists())
}
pub fn ids(&self) -> Vec<String> {
let mut result = Vec::new();
for item in self.items.iter() {
result.push(item.id.clone());
}
result.sort();
result
}
pub fn contains(&self, id: &String) -> bool {
let item = Item::from_empty(id.clone());
self.items.contains(&item)
}
// TODO: return Result<Item>
pub fn get(&self, id: &String) -> &Item {
let item = Item::from_empty(id.clone());
self.items.get(&item).unwrap()
}
/// Counting starts from 1, according to UI
pub fn get_id_by_number(&self, number: u32) -> io::Result<String> {
let number = number as usize;
if number == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Items numbering starts from 1"
));
}
let ids = self.ids();
if number > ids.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("There are only {} items, but asked with number {}", ids.len(), number)
));
}
Ok(ids[number - 1].clone())
}
pub fn add(&mut self, item: Item) {
self.items.insert(item);
}
pub fn update(&mut self, item: Item) {
self.items.remove(&item);
self.items.insert(item);
}
pub fn remove(&mut self, id: &String) {
let item = Item::from_empty(id.clone());
self.items.remove(&item);
}
pub fn dump(&self) -> io::Result<()> {
self.dump_db()?;
git::Git::sync()?;
Ok(())
}
fn dump_db(&self) -> io::Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(true) // Clear the file content before writing
.create(true)
.open(paths::get_db_path()?)?;
writeln!(file, "{}", self.encoder.get_encoded_test_passphrase()?)?;
for item in self.items.iter() {
writeln!(file, "{}", self.encoder.encrypt(&item.id)?)?;
let content = self.encoder.encrypt(&item.content)?;
writeln!(file, "{}", content)?;
}
Ok(())
}
pub fn new_from_passphrase(&self, passphrase: &String) -> Storage {
return Storage {
encoder: Encoder::from(passphrase),
items: self.items.clone()
}
}
}
Loading…
Cancel
Save