diff --git a/src/client.rs b/src/client.rs index 6ad1537..157716a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -pub mod command; +pub mod commands; #[cfg(feature = "mysql-admutils-compatibility")] pub mod mysql_admutils_compatibility; diff --git a/src/client/command.rs b/src/client/command.rs deleted file mode 100644 index 4788f26..0000000 --- a/src/client/command.rs +++ /dev/null @@ -1,899 +0,0 @@ -use std::collections::BTreeSet; - -use anyhow::Context; -use clap::Parser; -use dialoguer::{Confirm, Editor, Password}; -use futures_util::{SinkExt, StreamExt}; -use nix::unistd::{User, getuid}; -use prettytable::{Cell, Row, Table}; - -use crate::{ - core::{ - common::yn, - database_privileges::{ - DATABASE_PRIVILEGE_FIELDS, DatabasePrivilegeEditEntry, DatabasePrivilegeRow, - DatabasePrivilegeRowDiff, DatabasePrivilegesDiff, create_or_modify_privilege_rows, - db_priv_field_human_readable_name, diff_privileges, display_privilege_diffs, - generate_editor_content_from_privilege_data, parse_privilege_data_from_editor_content, - reduce_privilege_diffs, - }, - protocol::{ - ClientToServerMessageStream, ListUsersError, MySQLDatabase, MySQLUser, Request, - Response, print_create_databases_output_status, - print_create_databases_output_status_json, print_create_users_output_status, - print_create_users_output_status_json, print_drop_databases_output_status, - print_drop_databases_output_status_json, print_drop_users_output_status, - print_drop_users_output_status_json, print_lock_users_output_status, - print_lock_users_output_status_json, print_modify_database_privileges_output_status, - print_set_password_output_status, print_unlock_users_output_status, - print_unlock_users_output_status_json, - }, - }, -}; - -#[derive(Parser, Debug, Clone)] -pub enum ClientCommand { - /// Create one or more databases - #[command()] - CreateDb(DatabaseCreateArgs), - - /// Delete one or more databases - #[command()] - DropDb(DatabaseDropArgs), - - /// Print information about one or more databases - /// - /// If no database name is provided, all databases you have access will be shown. - #[command()] - ShowDb(DatabaseShowArgs), - - /// Print user privileges for one or more databases - /// - /// If no database names are provided, all databases you have access to will be shown. - #[command()] - ShowDbPrivs(DatabaseShowPrivsArgs), - - /// Change user privileges for one or more databases. See `edit-db-privs --help` for details. - /// - /// This command has two modes of operation: - /// - /// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the privileges using a text editor. - /// - /// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables. - /// - /// Follow the instructions inside the editor for more information. - /// - /// 2. Non-interactive mode: If the `-p` flag is specified, the user can write privileges using arguments. - /// - /// The privilege arguments should be formatted as `:+-` - /// where the privileges are a string of characters, each representing a single privilege. - /// The character `A` is an exception - it represents all privileges. - /// - /// The character-to-privilege mapping is defined as follows: - /// - /// - `s` - SELECT - /// - `i` - INSERT - /// - `u` - UPDATE - /// - `d` - DELETE - /// - `c` - CREATE - /// - `D` - DROP - /// - `a` - ALTER - /// - `I` - INDEX - /// - `t` - CREATE TEMPORARY TABLES - /// - `l` - LOCK TABLES - /// - `r` - REFERENCES - /// - `A` - ALL PRIVILEGES - /// - /// If you provide a database name, you can omit it from the privilege string, - /// e.g. `edit-db-privs my_db -p my_user+siu` is equivalent to `edit-db-privs -p my_db:my_user:siu`. - /// While it doesn't make much of a difference for a single edit, it can be useful for editing multiple users - /// on the same database at once. - /// - /// Example usage of non-interactive mode: - /// - /// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`: - /// - /// `mysqladm edit-db-privs -p my_db:my_user:siu` - /// - /// Enable all privileges for user `my_other_user` on database `my_other_db`: - /// - /// `mysqladm edit-db-privs -p my_other_db:my_other_user:A` - /// - /// Set miscellaneous privileges for multiple users on database `my_db`: - /// - /// `mysqladm edit-db-privs my_db -p my_user:siu my_other_user:ct`` - /// - #[command(verbatim_doc_comment)] - EditDbPrivs(DatabaseEditPrivsArgs), - - /// Create one or more users - #[command()] - CreateUser(UserCreateArgs), - - /// Delete one or more users - #[command()] - DropUser(UserDeleteArgs), - - /// Change the MySQL password for a user - #[command()] - PasswdUser(UserPasswdArgs), - - /// Print information about one or more users - /// - /// If no username is provided, all users you have access will be shown. - #[command()] - ShowUser(UserShowArgs), - - /// Lock account for one or more users - #[command()] - LockUser(UserLockArgs), - - /// Unlock account for one or more users - #[command()] - UnlockUser(UserUnlockArgs), -} - -#[derive(Parser, Debug, Clone)] -pub struct DatabaseCreateArgs { - /// The name of the database(s) to create - #[arg(num_args = 1..)] - name: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct DatabaseDropArgs { - /// The name of the database(s) to drop - #[arg(num_args = 1..)] - name: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct DatabaseShowArgs { - /// The name of the database(s) to show - #[arg(num_args = 0..)] - name: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct DatabaseShowPrivsArgs { - /// The name of the database(s) to show - #[arg(num_args = 0..)] - name: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct DatabaseEditPrivsArgs { - /// The name of the database to edit privileges for - pub name: Option, - - #[arg(short, long, value_name = "[DATABASE:]USER:[+-]PRIVILEGES", num_args = 0.., value_parser = DatabasePrivilegeEditEntry::parse_from_str)] - pub privs: Vec, - - /// Print the information as JSON - #[arg(short, long)] - pub json: bool, - - /// Specify the text editor to use for editing privileges - #[arg(short, long)] - pub editor: Option, - - /// Disable interactive confirmation before saving changes - #[arg(short, long)] - pub yes: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserCreateArgs { - #[arg(num_args = 1..)] - username: Vec, - - /// Do not ask for a password, leave it unset - #[clap(long)] - no_password: bool, - - /// Print the information as JSON - /// - /// Note that this implies `--no-password`, since the command will become non-interactive. - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserDeleteArgs { - #[arg(num_args = 1..)] - username: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserPasswdArgs { - username: MySQLUser, - - #[clap(short, long)] - password_file: Option, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserShowArgs { - #[arg(num_args = 0..)] - username: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserLockArgs { - #[arg(num_args = 1..)] - username: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -#[derive(Parser, Debug, Clone)] -pub struct UserUnlockArgs { - #[arg(num_args = 1..)] - username: Vec, - - /// Print the information as JSON - #[arg(short, long)] - json: bool, -} - -pub async fn handle_command( - command: ClientCommand, - server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - match command { - ClientCommand::CreateDb(args) => create_databases(args, server_connection).await, - ClientCommand::DropDb(args) => drop_databases(args, server_connection).await, - ClientCommand::ShowDb(args) => show_databases(args, server_connection).await, - ClientCommand::ShowDbPrivs(args) => show_database_privileges(args, server_connection).await, - ClientCommand::EditDbPrivs(args) => edit_database_privileges(args, server_connection).await, - ClientCommand::CreateUser(args) => create_users(args, server_connection).await, - ClientCommand::DropUser(args) => drop_users(args, server_connection).await, - ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await, - ClientCommand::ShowUser(args) => show_users(args, server_connection).await, - ClientCommand::LockUser(args) => lock_users(args, server_connection).await, - ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await, - } -} - -pub fn erroneous_server_response( - response: Option>, -) -> anyhow::Result<()> { - match response { - Some(Ok(Response::Error(e))) => { - anyhow::bail!("Server returned error: {}", e); - } - Some(Err(e)) => { - anyhow::bail!(e); - } - Some(response) => { - anyhow::bail!("Unexpected response from server: {:?}", response); - } - None => { - anyhow::bail!("No response from server"); - } - } -} - -async fn create_databases( - args: DatabaseCreateArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.name.is_empty() { - anyhow::bail!("No database names provided"); - } - - let message = Request::CreateDatabases(args.name.to_owned()); - server_connection.send(message).await?; - - let result = match server_connection.next().await { - Some(Ok(Response::CreateDatabases(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - print_create_databases_output_status_json(&result); - } else { - print_create_databases_output_status(&result); - } - - Ok(()) -} - -async fn drop_databases( - args: DatabaseDropArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.name.is_empty() { - anyhow::bail!("No database names provided"); - } - - let message = Request::DropDatabases(args.name.to_owned()); - server_connection.send(message).await?; - - let result = match server_connection.next().await { - Some(Ok(Response::DropDatabases(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - print_drop_databases_output_status_json(&result); - } else { - print_drop_databases_output_status(&result); - }; - - Ok(()) -} - -async fn show_databases( - args: DatabaseShowArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - let message = if args.name.is_empty() { - Request::ListDatabases(None) - } else { - Request::ListDatabases(Some(args.name.to_owned())) - }; - - server_connection.send(message).await?; - - // TODO: collect errors for json output. - - let database_list = match server_connection.next().await { - Some(Ok(Response::ListDatabases(databases))) => databases - .into_iter() - .filter_map(|(database_name, result)| match result { - Ok(database_row) => Some(database_row), - Err(err) => { - eprintln!("{}", err.to_error_message(&database_name)); - eprintln!("Skipping..."); - println!(); - None - } - }) - .collect::>(), - Some(Ok(Response::ListAllDatabases(database_list))) => match database_list { - Ok(list) => list, - Err(err) => { - server_connection.send(Request::Exit).await?; - return Err( - anyhow::anyhow!(err.to_error_message()).context("Failed to list databases") - ); - } - }, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - println!("{}", serde_json::to_string_pretty(&database_list)?); - } else if database_list.is_empty() { - println!("No databases to show."); - } else { - let mut table = Table::new(); - table.add_row(Row::new(vec![Cell::new("Database")])); - for db in database_list { - table.add_row(row![db.database]); - } - table.printstd(); - } - - Ok(()) -} - -async fn show_database_privileges( - args: DatabaseShowPrivsArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - let message = if args.name.is_empty() { - Request::ListPrivileges(None) - } else { - Request::ListPrivileges(Some(args.name.to_owned())) - }; - server_connection.send(message).await?; - - let privilege_data = match server_connection.next().await { - Some(Ok(Response::ListPrivileges(databases))) => databases - .into_iter() - .filter_map(|(database_name, result)| match result { - Ok(privileges) => Some(privileges), - Err(err) => { - eprintln!("{}", err.to_error_message(&database_name)); - eprintln!("Skipping..."); - println!(); - None - } - }) - .flatten() - .collect::>(), - Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows { - Ok(list) => list, - Err(err) => { - server_connection.send(Request::Exit).await?; - return Err(anyhow::anyhow!(err.to_error_message()) - .context("Failed to list database privileges")); - } - }, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - println!("{}", serde_json::to_string_pretty(&privilege_data)?); - } else if privilege_data.is_empty() { - println!("No database privileges to show."); - } else { - let mut table = Table::new(); - table.add_row(Row::new( - DATABASE_PRIVILEGE_FIELDS - .into_iter() - .map(db_priv_field_human_readable_name) - .map(|name| Cell::new(&name)) - .collect(), - )); - - for row in privilege_data { - table.add_row(row![ - row.db, - row.user, - c->yn(row.select_priv), - c->yn(row.insert_priv), - c->yn(row.update_priv), - c->yn(row.delete_priv), - c->yn(row.create_priv), - c->yn(row.drop_priv), - c->yn(row.alter_priv), - c->yn(row.index_priv), - c->yn(row.create_tmp_table_priv), - c->yn(row.lock_tables_priv), - c->yn(row.references_priv), - ]); - } - table.printstd(); - } - - Ok(()) -} - -pub async fn edit_database_privileges( - args: DatabaseEditPrivsArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name])); - - server_connection.send(message).await?; - - let existing_privilege_rows = match server_connection.next().await { - Some(Ok(Response::ListPrivileges(databases))) => databases - .into_iter() - .filter_map(|(database_name, result)| match result { - Ok(privileges) => Some(privileges), - Err(err) => { - eprintln!("{}", err.to_error_message(&database_name)); - eprintln!("Skipping..."); - println!(); - None - } - }) - .flatten() - .collect::>(), - Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows { - Ok(list) => list, - Err(err) => { - server_connection.send(Request::Exit).await?; - return Err(anyhow::anyhow!(err.to_error_message()) - .context("Failed to list database privileges")); - } - }, - response => return erroneous_server_response(response), - }; - - let diffs: BTreeSet = if !args.privs.is_empty() { - let privileges_to_change = parse_privilege_tables_from_args(&args)?; - create_or_modify_privilege_rows(&existing_privilege_rows, &privileges_to_change)? - } else { - let privileges_to_change = - edit_privileges_with_editor(&existing_privilege_rows, args.name.as_ref())?; - diff_privileges(&existing_privilege_rows, &privileges_to_change) - }; - let diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?; - - if diffs.is_empty() { - println!("No changes to make."); - server_connection.send(Request::Exit).await?; - return Ok(()); - } - - println!("The following changes will be made:\n"); - println!("{}", display_privilege_diffs(&diffs)); - - if !args.yes - && !Confirm::new() - .with_prompt("Do you want to apply these changes?") - .default(false) - .show_default(true) - .interact()? - { - server_connection.send(Request::Exit).await?; - return Ok(()); - } - - let message = Request::ModifyPrivileges(diffs); - server_connection.send(message).await?; - - let result = match server_connection.next().await { - Some(Ok(Response::ModifyPrivileges(result))) => result, - response => return erroneous_server_response(response), - }; - - print_modify_database_privileges_output_status(&result); - - server_connection.send(Request::Exit).await?; - - Ok(()) -} - -fn parse_privilege_tables_from_args( - args: &DatabaseEditPrivsArgs, -) -> anyhow::Result> { - debug_assert!(!args.privs.is_empty()); - args.privs - .iter() - .map(|priv_edit_entry| { - priv_edit_entry - .as_database_privileges_diff(args.name.as_ref()) - .context(format!( - "Failed parsing database privileges: `{}`", - priv_edit_entry - )) - }) - .collect::>>() -} - -fn edit_privileges_with_editor( - privilege_data: &[DatabasePrivilegeRow], - database_name: Option<&MySQLDatabase>, -) -> anyhow::Result> { - let unix_user = User::from_uid(getuid()) - .context("Failed to look up your UNIX username") - .and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?; - - let editor_content = - generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name); - - // TODO: handle errors better here - let result = Editor::new().extension("tsv").edit(&editor_content)?; - - match result { - None => Ok(privilege_data.to_vec()), - Some(result) => parse_privilege_data_from_editor_content(result) - .context("Could not parse privilege data from editor"), - } -} - -async fn create_users( - args: UserCreateArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.username.is_empty() { - anyhow::bail!("No usernames provided"); - } - - let message = Request::CreateUsers(args.username.to_owned()); - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server")); - } - - let result = match server_connection.next().await { - Some(Ok(Response::CreateUsers(result))) => result, - response => return erroneous_server_response(response), - }; - - if args.json { - print_create_users_output_status_json(&result); - } else { - print_create_users_output_status(&result); - - let successfully_created_users = result - .iter() - .filter_map(|(username, result)| result.as_ref().ok().map(|_| username)) - .collect::>(); - - for username in successfully_created_users { - if !args.no_password - && Confirm::new() - .with_prompt(format!( - "Do you want to set a password for user '{}'?", - username - )) - .default(false) - .interact()? - { - let password = read_password_from_stdin_with_double_check(username)?; - let message = Request::PasswdUser(username.to_owned(), password); - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - match server_connection.next().await { - Some(Ok(Response::PasswdUser(result))) => { - print_set_password_output_status(&result, username) - } - response => return erroneous_server_response(response), - } - - println!(); - } - } - } - - server_connection.send(Request::Exit).await?; - - Ok(()) -} - -async fn drop_users( - args: UserDeleteArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.username.is_empty() { - anyhow::bail!("No usernames provided"); - } - - let message = Request::DropUsers(args.username.to_owned()); - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - let result = match server_connection.next().await { - Some(Ok(Response::DropUsers(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - print_drop_users_output_status_json(&result); - } else { - print_drop_users_output_status(&result); - } - - Ok(()) -} - -pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result { - 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 passwd_user( - args: UserPasswdArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - // TODO: create a "user" exists check" command - let message = Request::ListUsers(Some(vec![args.username.to_owned()])); - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - let response = match server_connection.next().await { - Some(Ok(Response::ListUsers(users))) => users, - response => return erroneous_server_response(response), - }; - match response - .get(&args.username) - .unwrap_or(&Err(ListUsersError::UserDoesNotExist)) - { - Ok(_) => {} - Err(err) => { - server_connection.send(Request::Exit).await?; - server_connection.close().await.ok(); - anyhow::bail!("{}", err.to_error_message(&args.username)); - } - } - - let password = if let Some(password_file) = args.password_file { - std::fs::read_to_string(password_file) - .context("Failed to read password file")? - .trim() - .to_string() - } else { - read_password_from_stdin_with_double_check(&args.username)? - }; - - let message = Request::PasswdUser(args.username.to_owned(), password); - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - let result = match server_connection.next().await { - Some(Ok(Response::PasswdUser(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - print_set_password_output_status(&result, &args.username); - - Ok(()) -} - -async fn show_users( - args: UserShowArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - let message = if args.username.is_empty() { - Request::ListUsers(None) - } else { - Request::ListUsers(Some(args.username.to_owned())) - }; - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - let users = match server_connection.next().await { - Some(Ok(Response::ListUsers(users))) => users - .into_iter() - .filter_map(|(username, result)| match result { - Ok(user) => Some(user), - Err(err) => { - eprintln!("{}", err.to_error_message(&username)); - eprintln!("Skipping..."); - None - } - }) - .collect::>(), - Some(Ok(Response::ListAllUsers(users))) => match users { - Ok(users) => users, - Err(err) => { - server_connection.send(Request::Exit).await?; - return Err( - anyhow::anyhow!(err.to_error_message()).context("Failed to list all users") - ); - } - }, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - println!( - "{}", - serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")? - ); - } else if users.is_empty() { - println!("No users to show."); - } else { - let mut table = prettytable::Table::new(); - table.add_row(row![ - "User", - "Password is set", - "Locked", - "Databases where user has privileges" - ]); - for user in users { - table.add_row(row![ - user.user, - user.has_password, - user.is_locked, - user.databases.join("\n") - ]); - } - table.printstd(); - } - - Ok(()) -} - -async fn lock_users( - args: UserLockArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.username.is_empty() { - anyhow::bail!("No usernames provided"); - } - - let message = Request::LockUsers(args.username.to_owned()); - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - let result = match server_connection.next().await { - Some(Ok(Response::LockUsers(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - print_lock_users_output_status_json(&result); - } else { - print_lock_users_output_status(&result); - } - - Ok(()) -} - -async fn unlock_users( - args: UserUnlockArgs, - mut server_connection: ClientToServerMessageStream, -) -> anyhow::Result<()> { - if args.username.is_empty() { - anyhow::bail!("No usernames provided"); - } - - let message = Request::UnlockUsers(args.username.to_owned()); - - if let Err(err) = server_connection.send(message).await { - server_connection.close().await.ok(); - anyhow::bail!(err); - } - - let result = match server_connection.next().await { - Some(Ok(Response::UnlockUsers(result))) => result, - response => return erroneous_server_response(response), - }; - - server_connection.send(Request::Exit).await?; - - if args.json { - print_unlock_users_output_status_json(&result); - } else { - print_unlock_users_output_status(&result); - } - - Ok(()) -} diff --git a/src/client/commands.rs b/src/client/commands.rs new file mode 100644 index 0000000..5de2718 --- /dev/null +++ b/src/client/commands.rs @@ -0,0 +1,167 @@ +mod create_db; +mod create_user; +mod drop_db; +mod drop_user; +mod edit_db_privs; +mod lock_user; +mod passwd_user; +mod show_db; +mod show_db_privs; +mod show_user; +mod unlock_user; + +pub use create_db::*; +pub use create_user::*; +pub use drop_db::*; +pub use drop_user::*; +pub use edit_db_privs::*; +pub use lock_user::*; +pub use passwd_user::*; +pub use show_db::*; +pub use show_db_privs::*; +pub use show_user::*; +pub use unlock_user::*; + +use clap::Parser; + +use crate::core::protocol::{ClientToServerMessageStream, Response}; + +#[derive(Parser, Debug, Clone)] +pub enum ClientCommand { + /// Create one or more databases + #[command()] + CreateDb(CreateDbArgs), + + /// Delete one or more databases + #[command()] + DropDb(DropDbArgs), + + /// Print information about one or more databases + /// + /// If no database name is provided, all databases you have access will be shown. + #[command()] + ShowDb(ShowDbArgs), + + /// Print user privileges for one or more databases + /// + /// If no database names are provided, all databases you have access to will be shown. + #[command()] + ShowDbPrivs(ShowDbPrivsArgs), + + /// Change user privileges for one or more databases. See `edit-db-privs --help` for details. + /// + /// This command has two modes of operation: + /// + /// 1. Interactive mode: If nothing else is specified, the user will be prompted to edit the privileges using a text editor. + /// + /// You can configure your preferred text editor by setting the `VISUAL` or `EDITOR` environment variables. + /// + /// Follow the instructions inside the editor for more information. + /// + /// 2. Non-interactive mode: If the `-p` flag is specified, the user can write privileges using arguments. + /// + /// The privilege arguments should be formatted as `:+-` + /// where the privileges are a string of characters, each representing a single privilege. + /// The character `A` is an exception - it represents all privileges. + /// + /// The character-to-privilege mapping is defined as follows: + /// + /// - `s` - SELECT + /// - `i` - INSERT + /// - `u` - UPDATE + /// - `d` - DELETE + /// - `c` - CREATE + /// - `D` - DROP + /// - `a` - ALTER + /// - `I` - INDEX + /// - `t` - CREATE TEMPORARY TABLES + /// - `l` - LOCK TABLES + /// - `r` - REFERENCES + /// - `A` - ALL PRIVILEGES + /// + /// If you provide a database name, you can omit it from the privilege string, + /// e.g. `edit-db-privs my_db -p my_user+siu` is equivalent to `edit-db-privs -p my_db:my_user:siu`. + /// While it doesn't make much of a difference for a single edit, it can be useful for editing multiple users + /// on the same database at once. + /// + /// Example usage of non-interactive mode: + /// + /// Enable privileges `SELECT`, `INSERT`, and `UPDATE` for user `my_user` on database `my_db`: + /// + /// `mysqladm edit-db-privs -p my_db:my_user:siu` + /// + /// Enable all privileges for user `my_other_user` on database `my_other_db`: + /// + /// `mysqladm edit-db-privs -p my_other_db:my_other_user:A` + /// + /// Set miscellaneous privileges for multiple users on database `my_db`: + /// + /// `mysqladm edit-db-privs my_db -p my_user:siu my_other_user:ct`` + /// + #[command(verbatim_doc_comment)] + EditDbPrivs(EditDbPrivsArgs), + + /// Create one or more users + #[command()] + CreateUser(CreateUserArgs), + + /// Delete one or more users + #[command()] + DropUser(DropUserArgs), + + /// Change the MySQL password for a user + #[command()] + PasswdUser(PasswdUserArgs), + + /// Print information about one or more users + /// + /// If no username is provided, all users you have access will be shown. + #[command()] + ShowUser(ShowUserArgs), + + /// Lock account for one or more users + #[command()] + LockUser(LockUserArgs), + + /// Unlock account for one or more users + #[command()] + UnlockUser(UnlockUserArgs), +} + +pub async fn handle_command( + command: ClientCommand, + server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + match command { + ClientCommand::CreateDb(args) => create_databases(args, server_connection).await, + ClientCommand::DropDb(args) => drop_databases(args, server_connection).await, + ClientCommand::ShowDb(args) => show_databases(args, server_connection).await, + ClientCommand::ShowDbPrivs(args) => show_database_privileges(args, server_connection).await, + ClientCommand::EditDbPrivs(args) => edit_database_privileges(args, server_connection).await, + ClientCommand::CreateUser(args) => create_users(args, server_connection).await, + ClientCommand::DropUser(args) => drop_users(args, server_connection).await, + ClientCommand::PasswdUser(args) => passwd_user(args, server_connection).await, + ClientCommand::ShowUser(args) => show_users(args, server_connection).await, + ClientCommand::LockUser(args) => lock_users(args, server_connection).await, + ClientCommand::UnlockUser(args) => unlock_users(args, server_connection).await, + } +} + +pub fn erroneous_server_response( + response: Option>, +) -> anyhow::Result<()> { + match response { + Some(Ok(Response::Error(e))) => { + anyhow::bail!("Server returned error: {}", e); + } + Some(Err(e)) => { + anyhow::bail!(e); + } + Some(response) => { + anyhow::bail!("Unexpected response from server: {:?}", response); + } + None => { + anyhow::bail!("No response from server"); + } + } +} diff --git a/src/client/commands/create_db.rs b/src/client/commands/create_db.rs new file mode 100644 index 0000000..53407af --- /dev/null +++ b/src/client/commands/create_db.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, MySQLDatabase, Request, Response, + print_create_databases_output_status, print_create_databases_output_status_json, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct CreateDbArgs { + /// The name of the database(s) to create + #[arg(num_args = 1..)] + name: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn create_databases( + args: CreateDbArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.name.is_empty() { + anyhow::bail!("No database names provided"); + } + + let message = Request::CreateDatabases(args.name.to_owned()); + server_connection.send(message).await?; + + let result = match server_connection.next().await { + Some(Ok(Response::CreateDatabases(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + print_create_databases_output_status_json(&result); + } else { + print_create_databases_output_status(&result); + } + + Ok(()) +} diff --git a/src/client/commands/create_user.rs b/src/client/commands/create_user.rs new file mode 100644 index 0000000..ea2ceca --- /dev/null +++ b/src/client/commands/create_user.rs @@ -0,0 +1,93 @@ +use clap::Parser; +use dialoguer::Confirm; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::{erroneous_server_response, read_password_from_stdin_with_double_check}, + core::protocol::{ + ClientToServerMessageStream, MySQLUser, Request, Response, + print_create_users_output_status, print_create_users_output_status_json, + print_set_password_output_status, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct CreateUserArgs { + #[arg(num_args = 1..)] + username: Vec, + + /// Do not ask for a password, leave it unset + #[clap(long)] + no_password: bool, + + /// Print the information as JSON + /// + /// Note that this implies `--no-password`, since the command will become non-interactive. + #[arg(short, long)] + json: bool, +} + +pub async fn create_users( + args: CreateUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let message = Request::CreateUsers(args.username.to_owned()); + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(anyhow::Error::from(err).context("Failed to communicate with server")); + } + + let result = match server_connection.next().await { + Some(Ok(Response::CreateUsers(result))) => result, + response => return erroneous_server_response(response), + }; + + if args.json { + print_create_users_output_status_json(&result); + } else { + print_create_users_output_status(&result); + + let successfully_created_users = result + .iter() + .filter_map(|(username, result)| result.as_ref().ok().map(|_| username)) + .collect::>(); + + for username in successfully_created_users { + if !args.no_password + && Confirm::new() + .with_prompt(format!( + "Do you want to set a password for user '{}'?", + username + )) + .default(false) + .interact()? + { + let password = read_password_from_stdin_with_double_check(username)?; + let message = Request::PasswdUser(username.to_owned(), password); + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + match server_connection.next().await { + Some(Ok(Response::PasswdUser(result))) => { + print_set_password_output_status(&result, username) + } + response => return erroneous_server_response(response), + } + + println!(); + } + } + } + + server_connection.send(Request::Exit).await?; + + Ok(()) +} diff --git a/src/client/commands/drop_db.rs b/src/client/commands/drop_db.rs new file mode 100644 index 0000000..e9c1aec --- /dev/null +++ b/src/client/commands/drop_db.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, MySQLDatabase, Request, Response, + print_drop_databases_output_status, print_drop_databases_output_status_json, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct DropDbArgs { + /// The name of the database(s) to drop + #[arg(num_args = 1..)] + name: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn drop_databases( + args: DropDbArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.name.is_empty() { + anyhow::bail!("No database names provided"); + } + + let message = Request::DropDatabases(args.name.to_owned()); + server_connection.send(message).await?; + + let result = match server_connection.next().await { + Some(Ok(Response::DropDatabases(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + print_drop_databases_output_status_json(&result); + } else { + print_drop_databases_output_status(&result); + }; + + Ok(()) +} diff --git a/src/client/commands/drop_user.rs b/src/client/commands/drop_user.rs new file mode 100644 index 0000000..2cfeeab --- /dev/null +++ b/src/client/commands/drop_user.rs @@ -0,0 +1,52 @@ +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, MySQLUser, Request, Response, print_drop_users_output_status, + print_drop_users_output_status_json, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct DropUserArgs { + #[arg(num_args = 1..)] + username: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn drop_users( + args: DropUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let message = Request::DropUsers(args.username.to_owned()); + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + let result = match server_connection.next().await { + Some(Ok(Response::DropUsers(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + print_drop_users_output_status_json(&result); + } else { + print_drop_users_output_status(&result); + } + + Ok(()) +} diff --git a/src/client/commands/edit_db_privs.rs b/src/client/commands/edit_db_privs.rs new file mode 100644 index 0000000..cc1f668 --- /dev/null +++ b/src/client/commands/edit_db_privs.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeSet; + +use anyhow::Context; +use clap::Parser; +use dialoguer::{Confirm, Editor}; +use futures_util::SinkExt; +use nix::unistd::{User, getuid}; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::{ + database_privileges::{ + DatabasePrivilegeEditEntry, DatabasePrivilegeRow, DatabasePrivilegeRowDiff, + DatabasePrivilegesDiff, create_or_modify_privilege_rows, diff_privileges, + display_privilege_diffs, generate_editor_content_from_privilege_data, + parse_privilege_data_from_editor_content, reduce_privilege_diffs, + }, + protocol::{ + ClientToServerMessageStream, MySQLDatabase, Request, Response, + print_modify_database_privileges_output_status, + }, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct EditDbPrivsArgs { + /// The name of the database to edit privileges for + pub name: Option, + + #[arg( + short, + long, + value_name = "[DATABASE:]USER:[+-]PRIVILEGES", + num_args = 0.., + value_parser = DatabasePrivilegeEditEntry::parse_from_str, + )] + pub privs: Vec, + + /// Print the information as JSON + #[arg(short, long)] + pub json: bool, + + /// Specify the text editor to use for editing privileges + #[arg(short, long)] + pub editor: Option, + + /// Disable interactive confirmation before saving changes + #[arg(short, long)] + pub yes: bool, +} + +pub async fn edit_database_privileges( + args: EditDbPrivsArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + let message = Request::ListPrivileges(args.name.to_owned().map(|name| vec![name])); + + server_connection.send(message).await?; + + let existing_privilege_rows = match server_connection.next().await { + Some(Ok(Response::ListPrivileges(databases))) => databases + .into_iter() + .filter_map(|(database_name, result)| match result { + Ok(privileges) => Some(privileges), + Err(err) => { + eprintln!("{}", err.to_error_message(&database_name)); + eprintln!("Skipping..."); + println!(); + None + } + }) + .flatten() + .collect::>(), + Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows { + Ok(list) => list, + Err(err) => { + server_connection.send(Request::Exit).await?; + return Err(anyhow::anyhow!(err.to_error_message()) + .context("Failed to list database privileges")); + } + }, + response => return erroneous_server_response(response), + }; + + let diffs: BTreeSet = if !args.privs.is_empty() { + let privileges_to_change = parse_privilege_tables_from_args(&args)?; + create_or_modify_privilege_rows(&existing_privilege_rows, &privileges_to_change)? + } else { + let privileges_to_change = + edit_privileges_with_editor(&existing_privilege_rows, args.name.as_ref())?; + diff_privileges(&existing_privilege_rows, &privileges_to_change) + }; + let diffs = reduce_privilege_diffs(&existing_privilege_rows, diffs)?; + + if diffs.is_empty() { + println!("No changes to make."); + server_connection.send(Request::Exit).await?; + return Ok(()); + } + + println!("The following changes will be made:\n"); + println!("{}", display_privilege_diffs(&diffs)); + + if !args.yes + && !Confirm::new() + .with_prompt("Do you want to apply these changes?") + .default(false) + .show_default(true) + .interact()? + { + server_connection.send(Request::Exit).await?; + return Ok(()); + } + + let message = Request::ModifyPrivileges(diffs); + server_connection.send(message).await?; + + let result = match server_connection.next().await { + Some(Ok(Response::ModifyPrivileges(result))) => result, + response => return erroneous_server_response(response), + }; + + print_modify_database_privileges_output_status(&result); + + server_connection.send(Request::Exit).await?; + + Ok(()) +} + +fn parse_privilege_tables_from_args( + args: &EditDbPrivsArgs, +) -> anyhow::Result> { + debug_assert!(!args.privs.is_empty()); + args.privs + .iter() + .map(|priv_edit_entry| { + priv_edit_entry + .as_database_privileges_diff(args.name.as_ref()) + .context(format!( + "Failed parsing database privileges: `{}`", + priv_edit_entry + )) + }) + .collect::>>() +} + +fn edit_privileges_with_editor( + privilege_data: &[DatabasePrivilegeRow], + database_name: Option<&MySQLDatabase>, +) -> anyhow::Result> { + let unix_user = User::from_uid(getuid()) + .context("Failed to look up your UNIX username") + .and_then(|u| u.ok_or(anyhow::anyhow!("Failed to look up your UNIX username")))?; + + let editor_content = + generate_editor_content_from_privilege_data(privilege_data, &unix_user.name, database_name); + + // TODO: handle errors better here + let result = Editor::new().extension("tsv").edit(&editor_content)?; + + match result { + None => Ok(privilege_data.to_vec()), + Some(result) => parse_privilege_data_from_editor_content(result) + .context("Could not parse privilege data from editor"), + } +} diff --git a/src/client/commands/lock_user.rs b/src/client/commands/lock_user.rs new file mode 100644 index 0000000..334c950 --- /dev/null +++ b/src/client/commands/lock_user.rs @@ -0,0 +1,52 @@ +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, MySQLUser, Request, Response, print_lock_users_output_status, + print_lock_users_output_status_json, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct LockUserArgs { + #[arg(num_args = 1..)] + username: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn lock_users( + args: LockUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let message = Request::LockUsers(args.username.to_owned()); + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + let result = match server_connection.next().await { + Some(Ok(Response::LockUsers(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + print_lock_users_output_status_json(&result); + } else { + print_lock_users_output_status(&result); + } + + Ok(()) +} diff --git a/src/client/commands/passwd_user.rs b/src/client/commands/passwd_user.rs new file mode 100644 index 0000000..307cc8c --- /dev/null +++ b/src/client/commands/passwd_user.rs @@ -0,0 +1,90 @@ +use anyhow::Context; +use clap::Parser; +use dialoguer::Password; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, ListUsersError, MySQLUser, Request, Response, + print_set_password_output_status, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct PasswdUserArgs { + username: MySQLUser, + + #[clap(short, long)] + password_file: Option, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub fn read_password_from_stdin_with_double_check(username: &MySQLUser) -> anyhow::Result { + 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) +} + +pub async fn passwd_user( + args: PasswdUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + // TODO: create a "user" exists check" command + let message = Request::ListUsers(Some(vec![args.username.to_owned()])); + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + let response = match server_connection.next().await { + Some(Ok(Response::ListUsers(users))) => users, + response => return erroneous_server_response(response), + }; + match response + .get(&args.username) + .unwrap_or(&Err(ListUsersError::UserDoesNotExist)) + { + Ok(_) => {} + Err(err) => { + server_connection.send(Request::Exit).await?; + server_connection.close().await.ok(); + anyhow::bail!("{}", err.to_error_message(&args.username)); + } + } + + let password = if let Some(password_file) = args.password_file { + std::fs::read_to_string(password_file) + .context("Failed to read password file")? + .trim() + .to_string() + } else { + read_password_from_stdin_with_double_check(&args.username)? + }; + + let message = Request::PasswdUser(args.username.to_owned(), password); + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + let result = match server_connection.next().await { + Some(Ok(Response::PasswdUser(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + print_set_password_output_status(&result, &args.username); + + Ok(()) +} diff --git a/src/client/commands/show_db.rs b/src/client/commands/show_db.rs new file mode 100644 index 0000000..d1590bf --- /dev/null +++ b/src/client/commands/show_db.rs @@ -0,0 +1,77 @@ +use clap::Parser; +use futures_util::SinkExt; +use prettytable::{Cell, Row, Table}; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ClientToServerMessageStream, MySQLDatabase, Request, Response}, +}; + +#[derive(Parser, Debug, Clone)] +pub struct ShowDbArgs { + /// The name of the database(s) to show + #[arg(num_args = 0..)] + name: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn show_databases( + args: ShowDbArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + let message = if args.name.is_empty() { + Request::ListDatabases(None) + } else { + Request::ListDatabases(Some(args.name.to_owned())) + }; + + server_connection.send(message).await?; + + // TODO: collect errors for json output. + + let database_list = match server_connection.next().await { + Some(Ok(Response::ListDatabases(databases))) => databases + .into_iter() + .filter_map(|(database_name, result)| match result { + Ok(database_row) => Some(database_row), + Err(err) => { + eprintln!("{}", err.to_error_message(&database_name)); + eprintln!("Skipping..."); + println!(); + None + } + }) + .collect::>(), + Some(Ok(Response::ListAllDatabases(database_list))) => match database_list { + Ok(list) => list, + Err(err) => { + server_connection.send(Request::Exit).await?; + return Err( + anyhow::anyhow!(err.to_error_message()).context("Failed to list databases") + ); + } + }, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&database_list)?); + } else if database_list.is_empty() { + println!("No databases to show."); + } else { + let mut table = Table::new(); + table.add_row(Row::new(vec![Cell::new("Database")])); + for db in database_list { + table.add_row(row![db.database]); + } + table.printstd(); + } + + Ok(()) +} diff --git a/src/client/commands/show_db_privs.rs b/src/client/commands/show_db_privs.rs new file mode 100644 index 0000000..e2d172b --- /dev/null +++ b/src/client/commands/show_db_privs.rs @@ -0,0 +1,99 @@ +use clap::Parser; +use futures_util::SinkExt; +use prettytable::{Cell, Row, Table}; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::{ + common::yn, + database_privileges::{DATABASE_PRIVILEGE_FIELDS, db_priv_field_human_readable_name}, + protocol::{ClientToServerMessageStream, MySQLDatabase, Request, Response}, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct ShowDbPrivsArgs { + /// The name of the database(s) to show + #[arg(num_args = 0..)] + name: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn show_database_privileges( + args: ShowDbPrivsArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + let message = if args.name.is_empty() { + Request::ListPrivileges(None) + } else { + Request::ListPrivileges(Some(args.name.to_owned())) + }; + server_connection.send(message).await?; + + let privilege_data = match server_connection.next().await { + Some(Ok(Response::ListPrivileges(databases))) => databases + .into_iter() + .filter_map(|(database_name, result)| match result { + Ok(privileges) => Some(privileges), + Err(err) => { + eprintln!("{}", err.to_error_message(&database_name)); + eprintln!("Skipping..."); + println!(); + None + } + }) + .flatten() + .collect::>(), + Some(Ok(Response::ListAllPrivileges(privilege_rows))) => match privilege_rows { + Ok(list) => list, + Err(err) => { + server_connection.send(Request::Exit).await?; + return Err(anyhow::anyhow!(err.to_error_message()) + .context("Failed to list database privileges")); + } + }, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&privilege_data)?); + } else if privilege_data.is_empty() { + println!("No database privileges to show."); + } else { + let mut table = Table::new(); + table.add_row(Row::new( + DATABASE_PRIVILEGE_FIELDS + .into_iter() + .map(db_priv_field_human_readable_name) + .map(|name| Cell::new(&name)) + .collect(), + )); + + for row in privilege_data { + table.add_row(row![ + row.db, + row.user, + c->yn(row.select_priv), + c->yn(row.insert_priv), + c->yn(row.update_priv), + c->yn(row.delete_priv), + c->yn(row.create_priv), + c->yn(row.drop_priv), + c->yn(row.alter_priv), + c->yn(row.index_priv), + c->yn(row.create_tmp_table_priv), + c->yn(row.lock_tables_priv), + c->yn(row.references_priv), + ]); + } + table.printstd(); + } + + Ok(()) +} diff --git a/src/client/commands/show_user.rs b/src/client/commands/show_user.rs new file mode 100644 index 0000000..a215cb4 --- /dev/null +++ b/src/client/commands/show_user.rs @@ -0,0 +1,89 @@ +use anyhow::Context; +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ClientToServerMessageStream, MySQLUser, Request, Response}, +}; + +#[derive(Parser, Debug, Clone)] +pub struct ShowUserArgs { + #[arg(num_args = 0..)] + username: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn show_users( + args: ShowUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + let message = if args.username.is_empty() { + Request::ListUsers(None) + } else { + Request::ListUsers(Some(args.username.to_owned())) + }; + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + let users = match server_connection.next().await { + Some(Ok(Response::ListUsers(users))) => users + .into_iter() + .filter_map(|(username, result)| match result { + Ok(user) => Some(user), + Err(err) => { + eprintln!("{}", err.to_error_message(&username)); + eprintln!("Skipping..."); + None + } + }) + .collect::>(), + Some(Ok(Response::ListAllUsers(users))) => match users { + Ok(users) => users, + Err(err) => { + server_connection.send(Request::Exit).await?; + return Err( + anyhow::anyhow!(err.to_error_message()).context("Failed to list all users") + ); + } + }, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&users).context("Failed to serialize users to JSON")? + ); + } else if users.is_empty() { + println!("No users to show."); + } else { + let mut table = prettytable::Table::new(); + table.add_row(row![ + "User", + "Password is set", + "Locked", + "Databases where user has privileges" + ]); + for user in users { + table.add_row(row![ + user.user, + user.has_password, + user.is_locked, + user.databases.join("\n") + ]); + } + table.printstd(); + } + + Ok(()) +} diff --git a/src/client/commands/unlock_user.rs b/src/client/commands/unlock_user.rs new file mode 100644 index 0000000..58021c0 --- /dev/null +++ b/src/client/commands/unlock_user.rs @@ -0,0 +1,52 @@ +use clap::Parser; +use futures_util::SinkExt; +use tokio_stream::StreamExt; + +use crate::{ + client::commands::erroneous_server_response, + core::protocol::{ + ClientToServerMessageStream, MySQLUser, Request, Response, + print_unlock_users_output_status, print_unlock_users_output_status_json, + }, +}; + +#[derive(Parser, Debug, Clone)] +pub struct UnlockUserArgs { + #[arg(num_args = 1..)] + username: Vec, + + /// Print the information as JSON + #[arg(short, long)] + json: bool, +} + +pub async fn unlock_users( + args: UnlockUserArgs, + mut server_connection: ClientToServerMessageStream, +) -> anyhow::Result<()> { + if args.username.is_empty() { + anyhow::bail!("No usernames provided"); + } + + let message = Request::UnlockUsers(args.username.to_owned()); + + if let Err(err) = server_connection.send(message).await { + server_connection.close().await.ok(); + anyhow::bail!(err); + } + + let result = match server_connection.next().await { + Some(Ok(Response::UnlockUsers(result))) => result, + response => return erroneous_server_response(response), + }; + + server_connection.send(Request::Exit).await?; + + if args.json { + print_unlock_users_output_status_json(&result); + } else { + print_unlock_users_output_status(&result); + } + + Ok(()) +} diff --git a/src/client/mysql_admutils_compatibility/mysql_dbadm.rs b/src/client/mysql_admutils_compatibility/mysql_dbadm.rs index b235504..10ed8fa 100644 --- a/src/client/mysql_admutils_compatibility/mysql_dbadm.rs +++ b/src/client/mysql_admutils_compatibility/mysql_dbadm.rs @@ -6,7 +6,7 @@ use tokio::net::UnixStream as TokioUnixStream; use crate::{ client::{ - command::{DatabaseEditPrivsArgs, edit_database_privileges, erroneous_server_response}, + commands::{EditDbPrivsArgs, edit_database_privileges, erroneous_server_response}, mysql_admutils_compatibility::{ common::trim_db_name_to_32_chars, error_messages::{ @@ -186,7 +186,7 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh Command::Drop(args) => drop_databases(args, message_stream).await, Command::Show(args) => show_databases(args, message_stream).await, Command::Editperm(args) => { - let edit_privileges_args = DatabaseEditPrivsArgs { + let edit_privileges_args = EditDbPrivsArgs { name: Some(args.database), privs: vec![], json: false, diff --git a/src/client/mysql_admutils_compatibility/mysql_useradm.rs b/src/client/mysql_admutils_compatibility/mysql_useradm.rs index 471c902..72e1b6a 100644 --- a/src/client/mysql_admutils_compatibility/mysql_useradm.rs +++ b/src/client/mysql_admutils_compatibility/mysql_useradm.rs @@ -7,12 +7,13 @@ use tokio::net::UnixStream as TokioUnixStream; use crate::{ client::{ - command::{erroneous_server_response, read_password_from_stdin_with_double_check}, mysql_admutils_compatibility::{ + commands::{erroneous_server_response, read_password_from_stdin_with_double_check}, + mysql_admutils_compatibility::{ common::trim_user_name_to_32_chars, error_messages::{ handle_create_user_error, handle_drop_user_error, handle_list_users_error, }, - } + }, }, core::{ bootstrap::bootstrap_server_connection_and_drop_privileges, diff --git a/src/main.rs b/src/main.rs index efa337e..cc3a3bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,7 +77,7 @@ struct Args { #[derive(Parser, Debug, Clone)] enum Command { #[command(flatten)] - Client(client::command::ClientCommand), + Client(client::commands::ClientCommand), #[command(hide = true)] Server(server::command::ServerArgs), @@ -239,7 +239,7 @@ fn tokio_run_command(command: Command, server_connection: StdUnixStream) -> anyh match command { Command::Client(client_args) => { - client::command::handle_command(client_args, message_stream).await + client::commands::handle_command(client_args, message_stream).await } Command::Server(_) => unreachable!(), Command::GenerateCompletions(_) => unreachable!(),