diff --git a/Cargo.lock b/Cargo.lock index 0bb73e5..4784185 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -409,7 +409,7 @@ dependencies = [ "atty", "bitflags", "clap_derive", - "clap_lex", + "clap_lex 0.2.4", "indexmap", "once_cell", "strsim", @@ -417,6 +417,20 @@ dependencies = [ "textwrap", ] +[[package]] +name = "clap" +version = "4.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb41c13df48950b20eb4cd0eefa618819469df1bffc49d11e8487c4ba0037e5" +dependencies = [ + "atty", + "bitflags", + "clap_lex 0.3.0", + "once_cell", + "strsim", + "termcolor", +] + [[package]] name = "clap_derive" version = "3.2.18" @@ -439,6 +453,15 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "clap_lex" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1852,6 +1875,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "portfolio-cli" +version = "0.1.0" +dependencies = [ + "clap 4.0.23", + "portfolio-core", + "portfolio-entity", + "sea-orm", + "tokio", + "url", +] + [[package]] name = "portfolio-core" version = "0.1.0" @@ -2339,7 +2374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "882af0d4cd3d6cc2f427d14a48e9f468b37c563b405ea486fd314ba18ca334d0" dependencies = [ "chrono", - "clap", + "clap 3.2.23", "dotenvy", "regex", "sea-schema", @@ -2368,7 +2403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fe6e594b078712e1e797b951b9b56e55d3cfa04aac8ea76eb4bed7c94c5910" dependencies = [ "async-trait", - "clap", + "clap 3.2.23", "dotenvy", "sea-orm", "sea-orm-cli", diff --git a/Cargo.toml b/Cargo.toml index d159131..9044149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" publish = false [workspace] -members = [".", "api", "core", "entity", "migration"] +members = [".", "api", "core", "entity", "migration", "cli"] [dependencies] portfolio-api = { path = "api" } diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..2a327db --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "portfolio-cli" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +url = "^2.3" +clap = { version = "^4.0", features = ["cargo"] } + +portfolio-entity = { path = "../entity" } +portfolio-core = { path = "../core" } + +[dependencies.tokio] +version = "^1.21" +features = [ + "macros", +] + +[dependencies.sea-orm] +version = "^0.10" +features = [ + "sqlx-sqlite", + # TODO: Migrate to rustls for better compatibility with various OS + "runtime-tokio-native-tls" +] diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..9c69d96 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; +use url::Url; + +use clap::{arg, command, value_parser, ArgAction, Command}; +use sea_orm::{Database, DatabaseConnection}; + +use ::entity::candidate::Entity as Candidate; +use ::entity::parent::Entity as Parent; +use sea_orm::*; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let clap = command!() + .propagate_version(true) + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand( + Command::new("portfolio") + .about("Database & Portfolio operations") + .arg( + arg!( + -d --database "URL to the database or sql file with postgres:// or sqlite://" + ) + .alias("url") + .required(true) + .value_parser(value_parser!(Url)), + ) + .arg( + arg!( + -p --portfolio "Path to the portfolio root" + ) + .required(true) + .value_parser(value_parser!(PathBuf)), + ) + .arg( + arg!( + -k --key "AGE private key for decryption" + ) + .required(true), + ) + ) + .subcommand( + Command::new("hash") + .about("Hash operations") + .arg( + arg!( + -i --input "Plaintext to hash" + ) + .required(true) + ), + ) + .subcommand( + Command::new("symmetric") + .about("Symmetric encryption operations") + .arg( + arg!( + -d --decrypt ... "Decrypt flag" + ) + .action(ArgAction::SetTrue) + .required(false) + ) + .arg( + arg!( + -i --input "Plaintext to encrypt/decrypt" + ) + .required(true) + ) + .arg( + arg!( + -k --key "Key for encryption/decryption" + ) + .required(true), + ) + ) + .subcommand( + Command::new("asymmetric") + .about("Asymmetric encryption operations") + .arg( + arg!( + -d --decrypt ... "Decrypt flag" + ) + .action(ArgAction::SetTrue) + .required(false) + ) + .arg( + arg!( + -i --input "Plaintext to encrypt/decrypt" + ) + .required(true) + ) + .arg( + arg!( + -k --key "Public key / Private key" + ) + .required(true), + ) + ) + .get_matches(); + + match clap.subcommand() { + Some(("portfolio", sub_matches)) => { + let sqlite_url = sub_matches.get_one::("database").unwrap(); + + println!("Connecting to {:?}", sqlite_url); + + if sqlite_url.scheme() != "sqlite" && sqlite_url.scheme() != "postgres" { + return Err("URL scheme postgres:// or sqlite:// required")?; + } + + let db: DatabaseConnection = Database::connect(sqlite_url.as_str()).await?; + + let entries = Candidate::find() + .join_rev( + JoinType::InnerJoin, + Parent::belongs_to(Candidate) + .from(::entity::parent::Column::Application) + .to(::entity::candidate::Column::Application) + .into(), + ) + .all(&db) + .await?; + + println!("Found {} entries", entries.len()); + } + Some(("hash", sub_matches)) => { + let input = sub_matches.get_one::("input").unwrap(); + + let hash = portfolio_core::crypto::hash_password(input.to_string()).await?; + + println!("{}", hash); + } + Some(("symmetric", sub_matches)) => { + let decrypt = sub_matches.get_one::("decrypt").unwrap(); + let input = sub_matches.get_one::("input").unwrap(); + let key = sub_matches.get_one::("key").unwrap(); + + let result = if !*decrypt { + portfolio_core::crypto::encrypt_password(input.to_string(), key.to_string()).await? + } else { + portfolio_core::crypto::decrypt_password(input.to_string(), key.to_string()).await? + }; + + println!("{}", result); + } + Some(("asymmetric", sub_matches)) => { + let decrypt = sub_matches.get_one::("decrypt").unwrap(); + let input = sub_matches.get_one::("input").unwrap(); + let key = sub_matches.get_one::("key").unwrap(); + + let result = if !*decrypt { + portfolio_core::crypto::encrypt_password_with_recipients(input, &vec![key]).await? + } else { + portfolio_core::crypto::decrypt_password_with_private_key(input, key).await.map_err(|e| e.to_string())? + }; + + println!("{}", result); + } + _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), + } + + Ok(()) +}