diff --git a/Cargo.toml b/Cargo.toml index b735edd..c8fd54d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } toml = "0.8.12" [features] +default = ["mysql-admutils-compatibility"] tui = ["dep:ratatui"] +mysql-admutils-compatibility = [] [[bin]] name = "mysqladm" @@ -33,3 +35,6 @@ path = "src/main.rs" strip = true lto = true codegen-units = 1 + +[build-dependencies] +anyhow = "1.0.82" diff --git a/README.md b/README.md index 193c146..117e1c8 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,28 @@ To stop and remove the container, run the following command: ```bash docker stop mariadb ``` + +## Compatibility mode with [mysql-admutils](https://git.pvv.ntnu.no/Projects/mysql-admutils) + +If you enable the feature flag `mysql-admutils-compatibility` (enabled by default), the output directory will contain two symlinks to the binary, `mysql-dbadm` and `mysql-useradm`. In the same fashion as busybox, the binary will react to its `argv[0]` and behave as if it was called with the corresponding name. While the internal functionality is written in rust, these modes strive to behave as similar as possible to the original programs. + +```bash +cargo build +./target/debug/mysql-dbadm --help +./target/debug/mysql-useradm --help +``` + +### Known deviations from the original programs + +- Added flags for database configuration, not present in the original programs +- `--help` output is formatted by clap in a modern style. +- `mysql-dbadm edit-perm` uses the new implementation. The idea was that the parsing + logic was too complex to be worth porting, and there wouldn't be any scripts depending + on this command anyway. As such, the new implementation is more user-friendly and only + brings positive changes. +- The new tools use the modern implementation to find it's configuration. If you compiled + the old programs with `--sysconfdir=`, you might have to provide `--config-file` + where the old program would just work by itself. +- The order in which some things are validated (e.g. whether you own a user, whether the + contains illegal characters, whether the user does or does not exist) might be different + from the original program, leading to the same command giving the errors in a different order. \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f4e675f --- /dev/null +++ b/build.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "mysql-admutils-compatibility")] +use anyhow::anyhow; +#[cfg(feature = "mysql-admutils-compatibility")] +use std::{env, os::unix::fs::symlink, path::PathBuf}; + +fn main() -> anyhow::Result<()> { + #[cfg(feature = "mysql-admutils-compatibility")] + { + // NOTE: This is slightly illegal, and depends on implementation details. + // But it is only here for ease of testing the compatibility layer, + // and not critical in any way. Considering the code is never going + // to be used as a library, it should be fine. + let target_profile_dir: PathBuf = PathBuf::from(env::var("OUT_DIR")?) + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .ok_or(anyhow!("Could not resolve target profile directory"))? + .to_path_buf(); + + dbg!(&target_profile_dir); + + if !target_profile_dir.join("mysql-useradm").exists() { + symlink( + target_profile_dir.join("mysqladm"), + target_profile_dir.join("mysql-useradm"), + )?; + } + + 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.rs b/src/cli.rs index e96a85e..e558735 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,2 +1,3 @@ pub mod database_command; pub mod user_command; +pub mod mysql_admutils_compatibility; diff --git a/src/cli/database_command.rs b/src/cli/database_command.rs index af6e79d..372bf64 100644 --- a/src/cli/database_command.rs +++ b/src/cli/database_command.rs @@ -124,22 +124,22 @@ pub struct DatabaseShowPermArgs { #[derive(Parser)] pub struct DatabaseEditPermArgs { /// The name of the database to edit permissions for. - name: Option, + pub name: Option, #[arg(short, long, value_name = "[DATABASE:]USER:PERMISSIONS", num_args = 0..)] - perm: Vec, + pub perm: Vec, /// Whether to output the information in JSON format. #[arg(short, long)] - json: bool, + pub json: bool, /// Specify the text editor to use for editing permissions. #[arg(short, long)] - editor: Option, + pub editor: Option, /// Disable interactive confirmation before saving changes. #[arg(short, long)] - yes: bool, + pub yes: bool, } pub async fn handle_command( @@ -410,7 +410,7 @@ fn format_privileges_line( .to_string() } -async fn edit_permissions( +pub async fn edit_permissions( args: DatabaseEditPermArgs, conn: &mut MySqlConnection, ) -> anyhow::Result<()> { diff --git a/src/cli/mysql_admutils_compatibility.rs b/src/cli/mysql_admutils_compatibility.rs new file mode 100644 index 0000000..fa5c322 --- /dev/null +++ b/src/cli/mysql_admutils_compatibility.rs @@ -0,0 +1,3 @@ +pub mod common; +pub mod mysql_dbadm; +pub mod mysql_useradm; \ No newline at end of file diff --git a/src/cli/mysql_admutils_compatibility/common.rs b/src/cli/mysql_admutils_compatibility/common.rs new file mode 100644 index 0000000..1a53f0f --- /dev/null +++ b/src/cli/mysql_admutils_compatibility/common.rs @@ -0,0 +1,80 @@ +use crate::core::common::{ + get_current_unix_user, validate_name_token, validate_ownership_by_user_prefix, +}; + +/// This enum is used to differentiate between database and user operations. +/// Their output are very similar, but there are slight differences in the words used. +pub enum DbOrUser { + Database, + User, +} + +impl DbOrUser { + pub fn lowercased(&self) -> String { + match self { + DbOrUser::Database => "database".to_string(), + DbOrUser::User => "user".to_string(), + } + } + + pub fn capitalized(&self) -> String { + match self { + DbOrUser::Database => "Database".to_string(), + DbOrUser::User => "User".to_string(), + } + } +} + +/// In contrast to the new implementation which reports errors on any invalid name +/// for any reason, mysql-admutils would only log the error and skip that particular +/// name. This function replicates that behavior. +pub fn filter_db_or_user_names( + names: Vec, + db_or_user: DbOrUser, +) -> anyhow::Result> { + let unix_user = get_current_unix_user()?; + let argv0 = std::env::args().next().unwrap_or_else(|| match db_or_user { + DbOrUser::Database => "mysql-dbadm".to_string(), + DbOrUser::User => "mysql-useradm".to_string(), + }); + + let filtered_names = names + .into_iter() + // NOTE: The original implementation would only copy the first 32 characters + // of the argument into it's internal buffer. We replicate that behavior + // here. + .map(|name| name.chars().take(32).collect::()) + .filter(|name| { + if let Err(_err) = validate_ownership_by_user_prefix(name, &unix_user) { + println!( + "You are not in charge of mysql-{}: '{}'. Skipping.", + db_or_user.lowercased(), + name + ); + return false; + } + true + }) + .filter(|name| { + // NOTE: while this also checks for the length of the name, + // the name is already truncated to 32 characters. So + // if there is an error, it's guaranteed to be due to + // invalid characters. + if let Err(_err) = validate_name_token(name) { + println!( + concat!( + "{}: {} name '{}' contains invalid characters.\n", + "Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. Skipping.", + ), + argv0, + db_or_user.capitalized(), + name + ); + return false; + } + true + }) + .collect(); + + Ok(filtered_names) +} diff --git a/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs b/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs new file mode 100644 index 0000000..49c8bb2 --- /dev/null +++ b/src/cli/mysql_admutils_compatibility/mysql_dbadm.rs @@ -0,0 +1,220 @@ +use clap::Parser; +use sqlx::MySqlConnection; + +use crate::{ + cli::{ + database_command, + mysql_admutils_compatibility::common::{filter_db_or_user_names, DbOrUser}, + }, + core::{ + config::{get_config, mysql_connection_from_config, GlobalConfigArgs}, + database_operations::{self, yn}, + }, +}; + +const HELP_DB_PERM: &str = r#" +Edit permissions for the DATABASE(s). Running this command will +spawn the editor stored in the $EDITOR environment variable. +(pico will be used if the variable is unset) + +The file should contain one line per user, starting with the +username and followed by ten Y/N-values seperated by whitespace. +Lines starting with # are ignored. + +The Y/N-values corresponds to the following mysql privileges: + Select - Enables use of SELECT + Insert - Enables use of INSERT + Update - Enables use of UPDATE + Delete - Enables use of DELETE + Create - Enables use of CREATE TABLE + Drop - Enables use of DROP TABLE + Alter - Enables use of ALTER TABLE + Index - Enables use of CREATE INDEX and DROP INDEX + Temp - Enables use of CREATE TEMPORARY TABLE + Lock - Enables use of LOCK TABLE + References - Enables use of REFERENCES +"#; + +#[derive(Parser)] +pub struct Args { + #[command(subcommand)] + pub command: Option, + + #[command(flatten)] + config_overrides: GlobalConfigArgs, + + /// Print help for the 'editperm' subcommand. + #[arg(long, global = true)] + pub help_editperm: bool, +} + +/// Create, drop or edit permissions for the DATABASE(s), +/// as determined by the COMMAND. +/// +/// This is a compatibility layer for the mysql-dbadm command. +/// Please consider using the newer mysqladm command instead. +#[derive(Parser)] +#[command( + version, + about, + disable_help_subcommand = true, + verbatim_doc_comment, +)] +pub enum Command { + /// create the DATABASE(s). + Create(CreateArgs), + + /// delete the DATABASE(s). + Drop(DatabaseDropArgs), + + /// give information about the DATABASE(s), or, if + /// none are given, all the ones you own. + Show(DatabaseShowArgs), + + // TODO: make this output more verbatim_doc_comment-like, + // without messing up the indentation. + + /// change permissions for the DATABASE(s). Your + /// favorite editor will be started, allowing you + /// to make changes to the permission table. + /// Run 'mysql-dbadm --help-editperm' for more + /// information. + EditPerm(EditPermArgs), +} + +#[derive(Parser)] +pub struct CreateArgs { + /// The name of the DATABASE(s) to create. + #[arg(num_args = 1..)] + name: Vec, +} + +#[derive(Parser)] +pub struct DatabaseDropArgs { + /// The name of the DATABASE(s) to drop. + #[arg(num_args = 1..)] + name: Vec, +} + +#[derive(Parser)] +pub struct DatabaseShowArgs { + /// The name of the DATABASE(s) to show. + #[arg(num_args = 0..)] + name: Vec, +} + +#[derive(Parser)] +pub struct EditPermArgs { + /// The name of the DATABASE to edit permissions for. + pub database: String, +} + +pub async fn main() -> anyhow::Result<()> { + let args: Args = Args::parse(); + + if args.help_editperm { + println!("{}", HELP_DB_PERM); + return Ok(()); + } + + let command = match args.command { + Some(command) => command, + None => { + println!( + "Try `{} --help' for more information.", + std::env::args().next().unwrap_or("mysql-dbadm".to_string()) + ); + return Ok(()); + } + }; + + let config = get_config(args.config_overrides)?; + let mut connection = mysql_connection_from_config(config).await?; + + match command { + Command::Create(args) => { + let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?; + for name in filtered_names { + database_operations::create_database(&name, &mut connection).await?; + println!("Database {} created.", name); + } + } + Command::Drop(args) => { + let filtered_names = filter_db_or_user_names(args.name, DbOrUser::Database)?; + for name in filtered_names { + database_operations::drop_database(&name, &mut connection).await?; + println!("Database {} dropped.", name); + } + } + Command::Show(args) => { + let names = if args.name.is_empty() { + database_operations::get_database_list(&mut connection).await? + } else { + filter_db_or_user_names(args.name, DbOrUser::Database)? + }; + + for name in names { + show_db(&name, &mut connection).await?; + } + } + Command::EditPerm(args) => { + // TODO: This does not accurately replicate the behavior of the old implementation. + // Hopefully, not many people rely on this in an automated fashion, as it + // is made to be interactive in nature. However, we should still try to + // replicate the old behavior as closely as possible. + let edit_permissions_args = database_command::DatabaseEditPermArgs { + name: Some(args.database), + perm: vec![], + json: false, + editor: None, + yes: false, + }; + + database_command::edit_permissions(edit_permissions_args, &mut connection).await?; + } + } + + Ok(()) +} + +async fn show_db(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { + // NOTE: mysql-dbadm show has a quirk where valid database names + // for non-existent databases will report with no users. + // This function should *not* check for db existence, only + // validate the names. + let permissions = database_operations::get_database_privileges(name, conn) + .await + .unwrap_or(vec![]); + + println!( + concat!( + "Database '{}':\n", + "# User Select Insert Update Delete Create Drop Alter Index Temp Lock References\n", + "# ---------------- ------ ------ ------ ------ ------ ---- ----- ----- ---- ---- ----------" + ), + name, + ); + if permissions.is_empty() { + println!("# (no permissions currently granted to any users)"); + } else { + for permission in permissions { + println!( + " {:<16} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {:<7} {}", + permission.user, + yn(permission.select_priv), + yn(permission.insert_priv), + yn(permission.update_priv), + yn(permission.delete_priv), + yn(permission.create_priv), + yn(permission.drop_priv), + yn(permission.alter_priv), + yn(permission.index_priv), + yn(permission.create_tmp_table_priv), + yn(permission.lock_tables_priv), + yn(permission.references_priv) + ); + } + } + + Ok(()) +} diff --git a/src/cli/mysql_admutils_compatibility/mysql_useradm.rs b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs new file mode 100644 index 0000000..5e8b3e0 --- /dev/null +++ b/src/cli/mysql_admutils_compatibility/mysql_useradm.rs @@ -0,0 +1,171 @@ +use clap::Parser; +use sqlx::MySqlConnection; + +use crate::{ + cli::{ + mysql_admutils_compatibility::common::{filter_db_or_user_names, DbOrUser}, + user_command, + }, + core::{ + common::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, + }, + }, +}; + +#[derive(Parser)] +pub struct Args { + #[command(subcommand)] + pub command: Option, + + #[command(flatten)] + config_overrides: GlobalConfigArgs, +} + +/// Create, delete or change password for the USER(s), +/// as determined by the COMMAND. +/// +/// This is a compatibility layer for the mysql-useradm command. +/// Please consider using the newer mysqladm command instead. +#[derive(Parser)] +#[command(version, about, disable_help_subcommand = true, verbatim_doc_comment)] +pub enum Command { + /// create the USER(s). + Create(CreateArgs), + + /// delete the USER(s). + Delete(DeleteArgs), + + /// change the MySQL password for the USER(s). + Passwd(PasswdArgs), + + /// give information about the USERS(s), or, if + /// none are given, all the users you have. + Show(ShowArgs), +} + +#[derive(Parser)] +pub struct CreateArgs { + /// The name of the USER(s) to create. + #[arg(num_args = 1..)] + name: Vec, +} + +#[derive(Parser)] +pub struct DeleteArgs { + /// The name of the USER(s) to delete. + #[arg(num_args = 1..)] + name: Vec, +} + +#[derive(Parser)] +pub struct PasswdArgs { + /// The name of the USER(s) to change the password for. + #[arg(num_args = 1..)] + name: Vec, +} + +#[derive(Parser)] +pub struct ShowArgs { + /// The name of the USER(s) to show. + #[arg(num_args = 0..)] + name: Vec, +} + +pub async fn main() -> anyhow::Result<()> { + let args: Args = Args::parse(); + + let command = match args.command { + Some(command) => command, + None => { + println!( + "Try `{} --help' for more information.", + std::env::args() + .next() + .unwrap_or("mysql-useradm".to_string()) + ); + return Ok(()); + } + }; + + let config = get_config(args.config_overrides)?; + let mut connection = mysql_connection_from_config(config).await?; + + match command { + Command::Create(args) => { + let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?; + for name in filtered_names { + create_database_user(&name, &mut connection).await?; + } + } + Command::Delete(args) => { + let filtered_names = filter_db_or_user_names(args.name, DbOrUser::User)?; + for name in filtered_names { + delete_database_user(&name, &mut connection).await?; + } + } + Command::Passwd(args) => passwd(args, connection).await?, + Command::Show(args) => show(args, connection).await?, + } + + Ok(()) +} + +async fn passwd(args: PasswdArgs, mut connection: 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`. + // This is moving the check before asking the user for the password, + // to avoid having them figure out that the user does not exist after they + // 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? { + println!( + "{}: User '{}' does not exist. You must create it first.", + std::env::args() + .next() + .unwrap_or("mysql-useradm".to_string()), + name, + ); + } else { + better_filtered_names.push(name); + } + } + + 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?; + println!("Password updated for user '{}'.", name); + } + + Ok(()) +} + +async fn show(args: ShowArgs, mut connection: 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() + } else { + filter_db_or_user_names(args.name, DbOrUser::User)? + }; + + 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 => {} + } + } + + Ok(()) +} diff --git a/src/cli/user_command.rs b/src/cli/user_command.rs index b17ee20..e9778c2 100644 --- a/src/cli/user_command.rs +++ b/src/cli/user_command.rs @@ -4,7 +4,7 @@ use anyhow::Context; use clap::Parser; use sqlx::{Connection, MySqlConnection}; -use crate::core::user_operations::validate_ownership_of_user_name; +use crate::core::user_operations::validate_user_name; #[derive(Parser)] pub struct UserArgs { @@ -102,6 +102,23 @@ async fn drop_users(args: UserDeleteArgs, conn: &mut MySqlConnection) -> anyhow: Ok(()) } +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) +} + async fn change_password_for_user( args: UserPasswdArgs, conn: &mut MySqlConnection, @@ -109,7 +126,7 @@ async fn change_password_for_user( // NOTE: although this also is checked in `set_password_for_database_user`, we check it here // to provide a more natural order of error messages. let unix_user = crate::core::common::get_current_unix_user()?; - validate_ownership_of_user_name(&args.username, &unix_user)?; + validate_user_name(&args.username, &unix_user)?; let password = if let Some(password_file) = args.password_file { std::fs::read_to_string(password_file) @@ -117,17 +134,7 @@ async fn change_password_for_user( .trim() .to_string() } else { - let pass1 = rpassword::prompt_password("Enter new password: ") - .context("Failed to read password")?; - - let pass2 = rpassword::prompt_password("Re-enter new password: ") - .context("Failed to read password")?; - - if pass1 != pass2 { - anyhow::bail!("Passwords do not match"); - } - - pass1 + read_password_from_stdin_with_double_check(&args.username)? }; crate::core::user_operations::set_password_for_database_user(&args.username, &password, conn) @@ -144,7 +151,7 @@ async fn show_users(args: UserShowArgs, conn: &mut MySqlConnection) -> anyhow::R } else { let mut result = vec![]; for username in args.username { - if let Err(e) = validate_ownership_of_user_name(&username, &unix_user) { + if let Err(e) = validate_user_name(&username, &unix_user) { eprintln!("{}", e); eprintln!("Skipping..."); continue; diff --git a/src/core/common.rs b/src/core/common.rs index 2f7118d..e5060c7 100644 --- a/src/core/common.rs +++ b/src/core/common.rs @@ -62,7 +62,30 @@ pub fn create_user_group_matching_regex(user: &User) -> String { } } -pub fn validate_prefix_for_user<'a>(name: &'a str, user: &User) -> anyhow::Result<&'a str> { +pub fn validate_name_token(name: &str) -> anyhow::Result<()> { + if name.is_empty() { + anyhow::bail!("Database name cannot be empty."); + } + + if name.len() > 64 { + anyhow::bail!("Database name is too long. Maximum length is 64 characters."); + } + + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + anyhow::bail!( + indoc! {r#" + Invalid characters in name: '{}' + + Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted. + "#}, + name + ); + } + + Ok(()) +} + +pub fn validate_ownership_by_user_prefix<'a>(name: &'a str, user: &User) -> anyhow::Result<&'a str> { let user_groups = get_unix_groups(user)?; let mut split_name = name.split('_'); diff --git a/src/core/database_operations.rs b/src/core/database_operations.rs index 9343988..49ff651 100644 --- a/src/core/database_operations.rs +++ b/src/core/database_operations.rs @@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize}; use sqlx::{mysql::MySqlRow, prelude::*, MySqlConnection}; use super::common::{ - create_user_group_matching_regex, get_current_unix_user, quote_identifier, - validate_prefix_for_user, + create_user_group_matching_regex, get_current_unix_user, quote_identifier, validate_name_token, validate_ownership_by_user_prefix }; pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { let user = get_current_unix_user()?; - validate_ownership_of_database_name(name, &user)?; + validate_database_name(name, &user)?; // NOTE: see the note about SQL injections in `validate_owner_of_database_name` sqlx::query(&format!("CREATE DATABASE {}", quote_identifier(name))) @@ -33,7 +32,7 @@ pub async fn create_database(name: &str, conn: &mut MySqlConnection) -> anyhow:: pub async fn drop_database(name: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { let user = get_current_unix_user()?; - validate_ownership_of_database_name(name, &user)?; + validate_database_name(name, &user)?; // NOTE: see the note about SQL injections in `validate_owner_of_database_name` sqlx::query(&format!("DROP DATABASE {}", quote_identifier(name))) @@ -213,7 +212,7 @@ pub async fn get_database_privileges( conn: &mut MySqlConnection, ) -> anyhow::Result> { let unix_user = get_current_unix_user()?; - validate_ownership_of_database_name(database_name, &unix_user)?; + validate_database_name(database_name, &unix_user)?; let result = sqlx::query_as::<_, DatabasePrivileges>(&format!( "SELECT {} FROM `db` WHERE `db` = ?", @@ -391,28 +390,9 @@ pub async fn apply_permission_diffs( /// properly. MySQL does not seem to allow for prepared statements, binding /// the database name as a parameter to the query. This means that we have /// to validate the database name ourselves to prevent SQL injection. -pub fn validate_ownership_of_database_name(name: &str, user: &User) -> anyhow::Result<()> { - if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { - anyhow::bail!( - indoc! {r#" - Database name '{}' contains invalid characters. - Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. - "#}, - name - ); - } - - if name.len() > 64 { - anyhow::bail!( - indoc! {r#" - Database name '{}' is too long. - Maximum length is 64 characters. - "#}, - name - ); - } - - validate_prefix_for_user(name, user).context("Invalid database name")?; +pub fn validate_database_name(name: &str, user: &User) -> anyhow::Result<()> { + validate_name_token(name).context("Invalid database name")?; + validate_ownership_by_user_prefix(name, user).context("Invalid database name")?; Ok(()) } diff --git a/src/core/user_operations.rs b/src/core/user_operations.rs index 0eefdad..ab7c0bf 100644 --- a/src/core/user_operations.rs +++ b/src/core/user_operations.rs @@ -1,19 +1,44 @@ use anyhow::Context; -use indoc::indoc; use nix::unistd::User; use serde::{Deserialize, Serialize}; use sqlx::{prelude::*, MySqlConnection}; use crate::core::common::quote_literal; -use super::common::{create_user_group_matching_regex, get_current_unix_user, validate_prefix_for_user}; +use super::common::{create_user_group_matching_regex, get_current_unix_user, validate_name_token, validate_ownership_by_user_prefix}; + +pub async fn user_exists(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result { + let unix_user = get_current_unix_user()?; + + validate_user_name(db_user, &unix_user)?; + + let user_exists = sqlx::query( + r#" + SELECT EXISTS( + SELECT 1 + FROM `mysql`.`user` + WHERE `User` = ? + ) + "#, + ) + .bind(db_user) + .fetch_one(conn) + .await? + .get::(0); + + Ok(user_exists) +} pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { let unix_user = get_current_unix_user()?; - validate_ownership_of_user_name(db_user, &unix_user)?; + validate_user_name(db_user, &unix_user)?; - // NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` + if user_exists(db_user, conn).await? { + anyhow::bail!("User '{}' already exists", db_user); + } + + // NOTE: see the note about SQL injections in `validate_ownership_of_user_name` sqlx::query(format!("CREATE USER {}@'%'", quote_literal(db_user),).as_str()) .execute(conn) .await?; @@ -24,9 +49,13 @@ pub async fn create_database_user(db_user: &str, conn: &mut MySqlConnection) -> pub async fn delete_database_user(db_user: &str, conn: &mut MySqlConnection) -> anyhow::Result<()> { let unix_user = get_current_unix_user()?; - validate_ownership_of_user_name(db_user, &unix_user)?; + validate_user_name(db_user, &unix_user)?; - // NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` + if !user_exists(db_user, conn).await? { + anyhow::bail!("User '{}' does not exist", db_user); + } + + // NOTE: see the note about SQL injections in `validate_ownership_of_user_name` sqlx::query(format!("DROP USER {}@'%'", quote_literal(db_user),).as_str()) .execute(conn) .await?; @@ -40,9 +69,13 @@ pub async fn set_password_for_database_user( conn: &mut MySqlConnection, ) -> anyhow::Result<()> { let unix_user = crate::core::common::get_current_unix_user()?; - validate_ownership_of_user_name(db_user, &unix_user)?; + validate_user_name(db_user, &unix_user)?; - // NOTE: see the note about SQL injections in `validate_ownershipt_of_user_name` + if !user_exists(db_user, conn).await? { + anyhow::bail!("User '{}' does not exist", db_user); + } + + // NOTE: see the note about SQL injections in `validate_ownership_of_user_name` sqlx::query( format!( "ALTER USER {}@'%' IDENTIFIED BY {}", @@ -57,6 +90,27 @@ pub async fn set_password_for_database_user( Ok(()) } +#[derive(sqlx::FromRow)] +#[sqlx(transparent)] +pub struct PasswordIsSet(bool); + +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)) +} + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct DatabaseUser { #[sqlx(rename = "User")] @@ -111,29 +165,9 @@ pub async fn get_database_user_for_user( /// properly. MySQL does not seem to allow for prepared statements, binding /// the database name as a parameter to the query. This means that we have /// to validate the database name ourselves to prevent SQL injection. -pub fn validate_ownership_of_user_name(name: &str, user: &User) -> anyhow::Result<()> { - if name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { - anyhow::bail!( - indoc! {r#" - Username '{}' contains invalid characters. - Only A-Z, a-z, 0-9, _ (underscore) and - (dash) permitted. - "#}, - name - ); - } - - // TODO: does the name have a length limit? - // if name.len() > 48 { - // anyhow::bail!( - // indoc! {r#" - // Username '{}' is too long. - // Maximum length is 48 characters. Skipping. - // "#}, - // name - // ); - // } - - validate_prefix_for_user(name, user).context(format!("Invalid username: '{}'", name))?; +pub fn validate_user_name(name: &str, user: &User) -> anyhow::Result<()> { + validate_name_token(name).context(format!("Invalid username: '{}'", name))?; + validate_ownership_by_user_prefix(name, user).context(format!("Invalid username: '{}'", name))?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 4e8289f..f7e6a1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,12 @@ #[macro_use] extern crate prettytable; +#[cfg(feature = "mysql-admutils-compatibility")] +use std::path::PathBuf; + +#[cfg(feature = "mysql-admutils-compatibility")] +use crate::cli::mysql_admutils_compatibility::{mysql_dbadm, mysql_useradm}; + use clap::Parser; mod cli; @@ -41,6 +47,22 @@ enum Command { #[tokio::main] async fn main() -> anyhow::Result<()> { env_logger::init(); + + #[cfg(feature = "mysql-admutils-compatibility")] + { + let argv0 = std::env::args().next().and_then(|s| { + PathBuf::from(s) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + }); + + match argv0.as_deref() { + Some("mysql-dbadm") => return mysql_dbadm::main().await, + Some("mysql-useradm") => return mysql_useradm::main().await, + _ => { /* fall through */ } + } + } + let args: Args = Args::parse(); let config = core::config::get_config(args.config_overrides)?; let connection = core::config::mysql_connection_from_config(config).await?;