diff --git a/Cargo.lock b/Cargo.lock index 8622456..60fbe8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,19 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode 0.3.6", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -381,6 +394,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -420,16 +446,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "edit" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f364860e764787163c8c8f58231003839be31276e821e2ad2092ddf496b1aa09" -dependencies = [ - "tempfile", - "which", -] - [[package]] name = "either" version = "1.11.0" @@ -439,6 +455,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -885,7 +907,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "edit", + "dialoguer", "env_logger", "indoc", "itertools", @@ -893,7 +915,6 @@ dependencies = [ "nix", "prettytable", "ratatui", - "rpassword", "serde", "serde_json", "sqlx", @@ -970,16 +991,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" version = "0.32.2" @@ -1091,7 +1102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46480520d1b77c9a3482d39939fcf96831537a250ec62d4fd8fbdf8e0302e781" dependencies = [ "csv", - "encode_unicode", + "encode_unicode 1.0.0", "is-terminal", "lazy_static", "term", @@ -1230,17 +1241,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.48.0", -] - [[package]] name = "rsa" version = "0.9.6" @@ -1261,16 +1261,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rtoolbox" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1410,6 +1400,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook" version = "0.3.17" @@ -1858,7 +1854,6 @@ dependencies = [ "bytes", "libc", "mio", - "num_cpus", "pin-project-lite", "socket2", "tokio-macros", @@ -2057,18 +2052,6 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix", -] - [[package]] name = "whoami" version = "1.5.1" diff --git a/Cargo.toml b/Cargo.toml index c8fd54d..216e6c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0.82" clap = { version = "4.5.4", features = ["derive"] } -edit = "0.1.5" +dialoguer = "0.11.0" env_logger = "0.11.3" indoc = "2.0.5" itertools = "0.12.1" @@ -14,11 +14,10 @@ log = "0.4.21" nix = { version = "0.28.0", features = ["user"] } prettytable = "0.10.0" ratatui = { version = "0.26.2", optional = true } -rpassword = "7.3.1" serde = "1.0.198" serde_json = "1.0.116" sqlx = { version = "0.7.4", features = ["runtime-tokio", "mysql", "tls-rustls"] } -tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1.37.0", features = ["rt", "macros"] } toml = "0.8.12" [features] diff --git a/build.rs b/build.rs index f4e675f..f5bce0e 100644 --- a/build.rs +++ b/build.rs @@ -17,20 +17,24 @@ fn main() -> anyhow::Result<()> { .ok_or(anyhow!("Could not resolve target profile directory"))? .to_path_buf(); - dbg!(&target_profile_dir); + if !target_profile_dir.exists() { + std::fs::create_dir_all(&target_profile_dir)?; + } if !target_profile_dir.join("mysql-useradm").exists() { symlink( target_profile_dir.join("mysqladm"), target_profile_dir.join("mysql-useradm"), - )?; + ) + .ok(); } if !target_profile_dir.join("mysql-dbadm").exists() { symlink( target_profile_dir.join("mysqladm"), target_profile_dir.join("mysql-dbadm"), - )?; + ) + .ok(); } } diff --git a/src/cli/database_command.rs b/src/cli/database_command.rs index 23c9fc3..ccad5cd 100644 --- a/src/cli/database_command.rs +++ b/src/cli/database_command.rs @@ -1,5 +1,6 @@ use anyhow::{anyhow, Context}; use clap::Parser; +use dialoguer::Editor; use indoc::indoc; use itertools::Itertools; use prettytable::{Cell, Row, Table}; @@ -7,7 +8,7 @@ use sqlx::{Connection, MySqlConnection}; use crate::core::{ self, - common::get_current_unix_user, + common::{close_database_connection, get_current_unix_user}, database_operations::{ apply_permission_diffs, db_priv_field_human_readable_name, diff_permissions, yn, DatabasePrivileges, DATABASE_PRIVILEGE_FIELDS, @@ -15,9 +16,6 @@ use crate::core::{ user_operations::user_exists, }; -// TODO: Support batch creation/dropping,showing of databases, -// using a comma-separated list of database names. - #[derive(Parser)] // #[command(next_help_heading = Some(DATABASE_COMMAND_HEADER))] pub enum DatabaseCommand { @@ -147,15 +145,21 @@ pub async fn handle_command( command: DatabaseCommand, mut conn: MySqlConnection, ) -> anyhow::Result<()> { - let result = match command { - DatabaseCommand::CreateDb(args) => create_databases(args, &mut conn).await, - DatabaseCommand::DropDb(args) => drop_databases(args, &mut conn).await, - DatabaseCommand::ListDb(args) => list_databases(args, &mut conn).await, - DatabaseCommand::ShowDbPerm(args) => show_databases(args, &mut conn).await, - DatabaseCommand::EditDbPerm(args) => edit_permissions(args, &mut conn).await, - }; + let result = conn + .transaction(|txn| { + Box::pin(async move { + match command { + DatabaseCommand::CreateDb(args) => create_databases(args, txn).await, + DatabaseCommand::DropDb(args) => drop_databases(args, txn).await, + DatabaseCommand::ListDb(args) => list_databases(args, txn).await, + DatabaseCommand::ShowDbPerm(args) => show_databases(args, txn).await, + DatabaseCommand::EditDbPerm(args) => edit_permissions(args, txn).await, + } + }) + }) + .await; - conn.close().await?; + close_database_connection(conn).await; result } @@ -494,27 +498,32 @@ pub async fn edit_permissions( longest_database_name, ); - let result = edit::edit_with_builder( - format!( - "{}\n{}\n{}", - comment, - header.join(" "), - if permission_data.is_empty() { - format!("# {}", example_line) - } else { - permission_data - .iter() - .map(|perm| { - format_privileges_line(perm, longest_username, longest_database_name) - }) - .join("\n") - } - ), - edit::Builder::new() - .prefix("database-permissions") - .suffix(".tsv") - .rand_bytes(10), - )?; + // TODO: handle errors better here + let result = Editor::new() + .extension("tsv") + .edit( + format!( + "{}\n{}\n{}", + comment, + header.join(" "), + if permission_data.is_empty() { + format!("# {}", example_line) + } else { + permission_data + .iter() + .map(|perm| { + format_privileges_line( + perm, + longest_username, + longest_database_name, + ) + }) + .join("\n") + } + ) + .as_str(), + )? + .unwrap(); parse_permission_data_from_editor(result) .context("Could not parse permission data from editor")? diff --git a/src/cli/mysql_admutils_compatibility/mysql_useradm.rs b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs index 5e8b3e0..a4b1381 100644 --- a/src/cli/mysql_admutils_compatibility/mysql_useradm.rs +++ b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs @@ -7,11 +7,11 @@ use crate::{ user_command, }, core::{ - common::get_current_unix_user, + common::{close_database_connection, get_current_unix_user}, config::{get_config, mysql_connection_from_config, GlobalConfigArgs}, user_operations::{ create_database_user, delete_database_user, get_all_database_users_for_unix_user, - password_is_set_for_database_user, set_password_for_database_user, user_exists, + get_database_user_for_user, set_password_for_database_user, user_exists, }, }, }; @@ -107,14 +107,16 @@ pub async fn main() -> anyhow::Result<()> { delete_database_user(&name, &mut connection).await?; } } - Command::Passwd(args) => passwd(args, connection).await?, - Command::Show(args) => show(args, connection).await?, + Command::Passwd(args) => passwd(args, &mut connection).await?, + Command::Show(args) => show(args, &mut connection).await?, } + close_database_connection(connection).await; + Ok(()) } -async fn passwd(args: PasswdArgs, mut connection: MySqlConnection) -> anyhow::Result<()> { +async fn passwd(args: PasswdArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?; // NOTE: this gets doubly checked during the call to `set_password_for_database_user`. @@ -123,7 +125,7 @@ async fn passwd(args: PasswdArgs, mut connection: MySqlConnection) -> anyhow::Re // have entered the password twice. let mut better_filtered_names = Vec::with_capacity(filtered_names.len()); for name in filtered_names.into_iter() { - if !user_exists(&name, &mut connection).await? { + if !user_exists(&name, connection).await? { println!( "{}: User '{}' does not exist. You must create it first.", std::env::args() @@ -138,32 +140,34 @@ async fn passwd(args: PasswdArgs, mut connection: MySqlConnection) -> anyhow::Re for name in better_filtered_names { let password = user_command::read_password_from_stdin_with_double_check(&name)?; - set_password_for_database_user(&name, &password, &mut connection).await?; + set_password_for_database_user(&name, &password, connection).await?; println!("Password updated for user '{}'.", name); } Ok(()) } -async fn show(args: ShowArgs, mut connection: MySqlConnection) -> anyhow::Result<()> { +async fn show(args: ShowArgs, connection: &mut MySqlConnection) -> anyhow::Result<()> { let users = if args.name.is_empty() { let unix_user = get_current_unix_user()?; - get_all_database_users_for_unix_user(&unix_user, &mut connection) - .await? - .into_iter() - .map(|u| u.user) - .collect() + get_all_database_users_for_unix_user(&unix_user, connection).await? } else { - filter_db_or_user_names(args.name, DbOrUser::User)? + let filtered_usernames = filter_db_or_user_names(args.name, DbOrUser::User)?; + let mut result = Vec::with_capacity(filtered_usernames.len()); + for username in filtered_usernames.iter() { + // TODO: fetch all users in one query + if let Some(user) = get_database_user_for_user(username, connection).await? { + result.push(user) + } + } + result }; for user in users { - let password_is_set = password_is_set_for_database_user(&user, &mut connection).await?; - - match password_is_set { - Some(true) => println!("User '{}': password set.", user), - Some(false) => println!("User '{}': no password set.", user), - None => {} + if user.has_password { + println!("User '{}': password set.", user.user); + } else { + println!("User '{}': no password set.", user.user); } } diff --git a/src/cli/user_command.rs b/src/cli/user_command.rs index e9778c2..3db7e80 100644 --- a/src/cli/user_command.rs +++ b/src/cli/user_command.rs @@ -2,9 +2,10 @@ use std::vec; use anyhow::Context; use clap::Parser; +use dialoguer::{Confirm, Password}; use sqlx::{Connection, MySqlConnection}; -use crate::core::user_operations::validate_user_name; +use crate::core::{common::close_database_connection, user_operations::validate_user_name}; #[derive(Parser)] pub struct UserArgs { @@ -37,6 +38,10 @@ pub enum UserCommand { pub struct UserCreateArgs { #[arg(num_args = 1..)] username: Vec, + + /// Do not ask for a password, leave it unset + #[clap(long)] + no_password: bool, } #[derive(Parser)] @@ -57,22 +62,30 @@ pub struct UserPasswdArgs { pub struct UserShowArgs { #[arg(num_args = 0..)] username: Vec, + + #[clap(short, long)] + json: bool, } pub async fn handle_command(command: UserCommand, mut conn: MySqlConnection) -> anyhow::Result<()> { - let result = match command { - UserCommand::CreateUser(args) => create_users(args, &mut conn).await, - UserCommand::DropUser(args) => drop_users(args, &mut conn).await, - UserCommand::PasswdUser(args) => change_password_for_user(args, &mut conn).await, - UserCommand::ShowUser(args) => show_users(args, &mut conn).await, - }; + let result = conn + .transaction(|txn| { + Box::pin(async move { + match command { + UserCommand::CreateUser(args) => create_users(args, txn).await, + UserCommand::DropUser(args) => drop_users(args, txn).await, + UserCommand::PasswdUser(args) => change_password_for_user(args, txn).await, + UserCommand::ShowUser(args) => show_users(args, txn).await, + } + }) + }) + .await; - conn.close().await?; + close_database_connection(conn).await; result } -// TODO: provide a better error message when the user already exists async fn create_users(args: UserCreateArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { if args.username.is_empty() { anyhow::bail!("No usernames provided"); @@ -81,13 +94,34 @@ async fn create_users(args: UserCreateArgs, conn: &mut MySqlConnection) -> anyho for username in args.username { if let Err(e) = crate::core::user_operations::create_database_user(&username, conn).await { eprintln!("{}", e); - eprintln!("Skipping..."); + eprintln!("Skipping...\n"); + continue; + } else { + println!("User '{}' created.", username); } + + if !args.no_password + && Confirm::new() + .with_prompt(format!( + "Do you want to set a password for user '{}'?", + username + )) + .interact()? + { + change_password_for_user( + UserPasswdArgs { + username, + password_file: None, + }, + conn, + ) + .await?; + } + println!(); } Ok(()) } -// TODO: provide a better error message when the user does not exist async fn drop_users(args: UserDeleteArgs, conn: &mut MySqlConnection) -> anyhow::Result<()> { if args.username.is_empty() { anyhow::bail!("No usernames provided"); @@ -103,20 +137,14 @@ async fn drop_users(args: UserDeleteArgs, conn: &mut MySqlConnection) -> anyhow: } pub fn read_password_from_stdin_with_double_check(username: &str) -> anyhow::Result { - let pass1 = rpassword::prompt_password(format!("New MySQL password for user '{}': ", username)) - .context("Failed to read password")?; - - let pass2 = rpassword::prompt_password(format!( - "Retype new MySQL password for user '{}': ", - username - )) - .context("Failed to read password")?; - - if pass1 != pass2 { - anyhow::bail!("Passwords do not match"); - } - - Ok(pass1) + Password::new() + .with_prompt(format!("New MySQL password for user '{}'", username)) + .with_confirmation( + format!("Retype new MySQL password for user '{}'", username), + "Passwords do not match", + ) + .interact() + .map_err(Into::into) } async fn change_password_for_user( @@ -168,16 +196,20 @@ async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::R result }; - for user in users { - println!( - "User '{}': {}", - &user.user, - if !(user.authentication_string.is_empty() && user.password.is_empty()) { - "password set." - } else { - "no password set." - } - ); + if args.json { + println!("{}", serde_json::to_string_pretty(&users)?); + } else { + for user in users { + println!( + "User '{}': {}", + &user.user, + if user.has_password { + "password set." + } else { + "no password set." + } + ); + } } Ok(()) diff --git a/src/core/common.rs b/src/core/common.rs index c641b14..d74cb0c 100644 --- a/src/core/common.rs +++ b/src/core/common.rs @@ -2,6 +2,7 @@ use anyhow::Context; use indoc::indoc; use itertools::Itertools; use nix::unistd::{getuid, Group, User}; +use sqlx::{Connection, MySqlConnection}; #[cfg(not(target_os = "macos"))] use std::ffi::CString; @@ -140,10 +141,23 @@ pub fn validate_ownership_by_user_prefix<'a>( Ok(prefix) } +pub async fn close_database_connection(conn: MySqlConnection) { + if let Err(e) = conn + .close() + .await + .context("Failed to close connection properly") + { + eprintln!("{}", e); + eprintln!("Ignoring..."); + } +} + +#[inline] pub fn quote_literal(s: &str) -> String { format!("'{}'", s.replace('\'', r"\'")) } +#[inline] pub fn quote_identifier(s: &str) -> String { format!("`{}`", s.replace('`', r"\`")) } diff --git a/src/core/user_operations.rs b/src/core/user_operations.rs index 125f77d..2b0c67e 100644 --- a/src/core/user_operations.rs +++ b/src/core/user_operations.rs @@ -93,30 +93,6 @@ pub async fn set_password_for_database_user( Ok(()) } -/// Helper struct to deserialize the query made in `password_is_set_for_database_user`. -#[derive(sqlx::FromRow)] -#[sqlx(transparent)] -struct PasswordIsSet(bool); - -/// This function checks if a database user has a password set. -/// It returns `Ok(None)` if the user does not exist. -pub async fn password_is_set_for_database_user( - db_user: &str, - conn: &mut MySqlConnection, -) -> anyhow::Result> { - let unix_user = crate::core::common::get_current_unix_user()?; - validate_user_name(db_user, &unix_user)?; - - let user_has_password = sqlx::query_as::<_, PasswordIsSet>( - "SELECT authentication_string != '' FROM mysql.user WHERE User = ?", - ) - .bind(db_user) - .fetch_optional(conn) - .await?; - - Ok(user_has_password.map(|PasswordIsSet(is_set)| is_set)) -} - /// This struct contains information about a database user. /// This can be extended if we need more information in the future. #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -124,13 +100,12 @@ pub struct DatabaseUser { #[sqlx(rename = "User")] pub user: String, + #[serde(skip)] #[sqlx(rename = "Host")] pub host: String, - #[sqlx(rename = "Password")] - pub password: String, - - pub authentication_string: String, + #[sqlx(rename = "`Password` != '' OR `authentication_string` != ''")] + pub has_password: bool, } /// This function fetches all database users that have a prefix matching the @@ -141,7 +116,10 @@ pub async fn get_all_database_users_for_unix_user( ) -> anyhow::Result> { let users = sqlx::query_as::<_, DatabaseUser>( r#" - SELECT `User`, `Host`, `Password`, `authentication_string` + SELECT + `User`, + `Host`, + `Password` != '' OR `authentication_string` != '' FROM `mysql`.`user` WHERE `User` REGEXP ? "#, @@ -160,7 +138,10 @@ pub async fn get_database_user_for_user( ) -> anyhow::Result> { let user = sqlx::query_as::<_, DatabaseUser>( r#" - SELECT `User`, `Host`, `Password`, `authentication_string` + SELECT + `User`, + `Host`, + `Password` != '' OR `authentication_string` != '' FROM `mysql`.`user` WHERE `User` = ? "#, diff --git a/src/main.rs b/src/main.rs index f7e6a1a..49d6ae5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ enum Command { User(cli::user_command::UserCommand), } -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { env_logger::init(); @@ -67,8 +67,15 @@ async fn main() -> anyhow::Result<()> { let config = core::config::get_config(args.config_overrides)?; let connection = core::config::mysql_connection_from_config(config).await?; - match args.command { + let result = match args.command { Command::Db(command) => cli::database_command::handle_command(command, connection).await, Command::User(user_args) => cli::user_command::handle_command(user_args, connection).await, + }; + + match result { + Ok(_) => println!("Changes committed to database"), + Err(_) => println!("Changes reverted due to error"), } + + result }