Add dynamic completion for users and databases
All checks were successful
Build and test / check (push) Successful in 1m35s
Build and test / build (push) Successful in 2m46s
Build and test / test (push) Successful in 3m10s
Build and test / check-license (push) Successful in 6m12s
Build and test / docs (push) Successful in 4m39s

This commit is contained in:
2025-12-01 17:26:17 +09:00
parent cb3f3f3e1d
commit f348e67622
26 changed files with 383 additions and 17 deletions

12
Cargo.lock generated
View File

@@ -242,6 +242,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992"
dependencies = [
"clap",
"clap_lex",
"is_executable",
"shlex",
]
[[package]]
@@ -923,6 +926,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is_executable"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"

View File

@@ -22,7 +22,7 @@ async-bincode = "0.8.0"
bincode = "2.0.1"
clap = { version = "4.5.53", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.4", features = [ "tracing" ] }
clap_complete = "4.5.61"
clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] }
derive_more = { version = "2.0.1", features = ["display", "error"] }
dialoguer = "0.12.0"
futures-util = "0.3.31"

View File

@@ -24,14 +24,18 @@ buildFunction {
nativeBuildInputs = [ installShellFiles ];
postInstall = let
# "$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/muscl.${shell}"
commands = lib.mapCartesianProduct ({ shell, command }: ''
"$out/bin/${mainProgram}" generate-completions --shell "${shell}" --command "${command}" > "$TMP/muscl.${shell}"
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/muscl.${shell}"
COMPLETE=${shell} "$out/bin/${command}" > "$TMP/${command}.${shell}"
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/${command}.${shell}"
'') {
shell = [ "bash" "zsh" "fish" ];
command = [ "muscl" "mysql-dbadm" "mysql-useradm" ];
};
in lib.concatStringsSep "\n" commands + ''
in ''
ln -sr "$out/bin/muscl" "$out/bin/mysql-dbadm"
ln -sr "$out/bin/muscl" "$out/bin/mysql-useradm"
'' + lib.concatStringsSep "\n" commands + ''
install -Dm444 assets/systemd/muscl.socket -t "$out/lib/systemd/system"
install -Dm644 assets/systemd/muscl.service -t "$out/lib/systemd/system"
substituteInPlace "$out/lib/systemd/system/muscl.service" \

View File

@@ -1,10 +1,12 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
protocol::{
ClientToServerMessageStream, Request, Response, print_drop_databases_output_status,
print_drop_databases_output_status_json,
@@ -16,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct DropDbArgs {
/// The MySQL database(s) to drop
#[arg(num_args = 1..)]
#[arg(num_args = 1.., add = ArgValueCompleter::new(mysql_database_completer))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON

View File

@@ -1,10 +1,12 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, print_drop_users_output_status,
print_drop_users_output_status_json,
@@ -16,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct DropUserArgs {
/// The MySQL user(s) to drop
#[arg(num_args = 1..)]
#[arg(num_args = 1.., add = ArgValueCompleter::new(mysql_user_completer))]
username: Vec<MySQLUser>,
/// Print the information as JSON

View File

@@ -2,6 +2,7 @@ use std::collections::BTreeSet;
use anyhow::Context;
use clap::Parser;
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, Editor};
use futures_util::SinkExt;
use nix::unistd::{User, getuid};
@@ -10,6 +11,7 @@ use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
database_privileges::{
DatabasePrivilegeEditEntry, DatabasePrivilegeRow, DatabasePrivilegeRowDiff,
DatabasePrivilegesDiff, create_or_modify_privilege_rows, diff_privileges,
@@ -27,6 +29,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct EditPrivsArgs {
/// The MySQL database to edit privileges for
#[arg(add = ArgValueCompleter::new(mysql_database_completer))]
pub name: Option<MySQLDatabase>,
#[arg(

View File

@@ -1,10 +1,12 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, print_lock_users_output_status,
print_lock_users_output_status_json,
@@ -16,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct LockUserArgs {
/// The MySQL user(s) to lock
#[arg(num_args = 1..)]
#[arg(num_args = 1.., add = ArgValueCompleter::new(mysql_user_completer))]
username: Vec<MySQLUser>,
/// Print the information as JSON

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use clap_complete::ArgValueCompleter;
use dialoguer::Password;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
@@ -9,6 +10,7 @@ use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, ListUsersError, Request, Response,
print_set_password_output_status,
@@ -20,6 +22,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct PasswdUserArgs {
/// The MySQL user whose password is to be changed
#[arg(add = ArgValueCompleter::new(mysql_user_completer))]
username: MySQLUser,
/// Read the new password from a file instead of prompting for it

View File

@@ -1,4 +1,5 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use prettytable::{Cell, Row, Table};
use tokio_stream::StreamExt;
@@ -6,6 +7,7 @@ use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_database_completer,
protocol::{ClientToServerMessageStream, Request, Response},
types::MySQLDatabase,
},
@@ -14,7 +16,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowDbArgs {
/// The MySQL database(s) to show
#[arg(num_args = 0..)]
#[arg(num_args = 0.., add = ArgValueCompleter::new(mysql_database_completer))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON

View File

@@ -1,4 +1,5 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use prettytable::{Cell, Row, Table};
use tokio_stream::StreamExt;
@@ -7,6 +8,7 @@ use crate::{
client::commands::erroneous_server_response,
core::{
common::yn,
completion::mysql_database_completer,
database_privileges::{DATABASE_PRIVILEGE_FIELDS, db_priv_field_human_readable_name},
protocol::{ClientToServerMessageStream, Request, Response},
types::MySQLDatabase,
@@ -16,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowPrivsArgs {
/// The MySQL database(s) to show privileges for
#[arg(num_args = 0..)]
#[arg(num_args = 0.., add = ArgValueCompleter::new(mysql_database_completer))]
name: Vec<MySQLDatabase>,
/// Print the information as JSON

View File

@@ -1,11 +1,13 @@
use anyhow::Context;
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{ClientToServerMessageStream, Request, Response},
types::MySQLUser,
},
@@ -14,7 +16,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct ShowUserArgs {
/// The MySQL user(s) to show
#[arg(num_args = 0..)]
#[arg(num_args = 0.., add = ArgValueCompleter::new(mysql_user_completer))]
username: Vec<MySQLUser>,
/// Print the information as JSON

View File

@@ -1,10 +1,12 @@
use clap::Parser;
use clap_complete::ArgValueCompleter;
use futures_util::SinkExt;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
completion::mysql_user_completer,
protocol::{
ClientToServerMessageStream, Request, Response, print_unlock_users_output_status,
print_unlock_users_output_status_json,
@@ -16,7 +18,7 @@ use crate::{
#[derive(Parser, Debug, Clone)]
pub struct UnlockUserArgs {
/// The MySQL user(s) to unlock
#[arg(num_args = 1..)]
#[arg(num_args = 1.., add = ArgValueCompleter::new(mysql_user_completer))]
username: Vec<MySQLUser>,
/// Print the information as JSON

View File

@@ -1,5 +1,6 @@
pub mod bootstrap;
pub mod common;
pub mod completion;
pub mod database_privileges;
pub mod protocol;
pub mod types;

View File

@@ -159,7 +159,7 @@ fn connect_to_external_server(
/// Drop privileges to the real user and group of the process.
/// If the process is not running with elevated privileges, this function
/// is a no-op.
fn drop_privs() -> anyhow::Result<()> {
pub fn drop_privs() -> anyhow::Result<()> {
tracing::debug!("Dropping privileges");
let real_uid = nix::unistd::getuid();
let real_gid = nix::unistd::getgid();

5
src/core/completion.rs Normal file
View File

@@ -0,0 +1,5 @@
mod mysql_database_completer;
mod mysql_user_completer;
pub use mysql_database_completer::*;
pub use mysql_user_completer::*;

View File

@@ -0,0 +1,73 @@
use clap_complete::CompletionCandidate;
use clap_verbosity_flag::Verbosity;
use futures_util::SinkExt;
use tokio::net::UnixStream as TokioUnixStream;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
protocol::{Request, Response, create_client_to_server_message_stream},
},
};
pub fn mysql_database_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(mysql_database_completer_(current)) {
Ok(completions) => completions,
Err(err) => {
eprintln!("Error getting MySQL database completions: {}", err);
Vec::new()
}
},
Err(err) => {
eprintln!("Error starting Tokio runtime: {}", err);
Vec::new()
}
}
}
/// Connect to the server to get MySQL database completions.
async fn mysql_database_completer_(
current: &std::ffi::OsStr,
) -> anyhow::Result<Vec<CompletionCandidate>> {
let server_connection =
bootstrap_server_connection_and_drop_privileges(None, None, Verbosity::new(0, 1))?;
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut server_connection = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = server_connection.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
let message = Request::CompleteDatabaseName(current.to_string_lossy().to_string());
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::CompleteDatabaseName(suggestions))) => suggestions,
response => return erroneous_server_response(response).map(|_| vec![]),
};
server_connection.send(Request::Exit).await?;
let result = result.into_iter().map(CompletionCandidate::new).collect();
Ok(result)
}

View File

@@ -0,0 +1,73 @@
use clap_complete::CompletionCandidate;
use clap_verbosity_flag::Verbosity;
use futures_util::SinkExt;
use tokio::net::UnixStream as TokioUnixStream;
use tokio_stream::StreamExt;
use crate::{
client::commands::erroneous_server_response,
core::{
bootstrap::bootstrap_server_connection_and_drop_privileges,
protocol::{Request, Response, create_client_to_server_message_stream},
},
};
pub fn mysql_user_completer(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(mysql_user_completer_(current)) {
Ok(completions) => completions,
Err(err) => {
eprintln!("Error getting MySQL user completions: {}", err);
Vec::new()
}
},
Err(err) => {
eprintln!("Error starting Tokio runtime: {}", err);
Vec::new()
}
}
}
/// Connect to the server to get MySQL user completions.
async fn mysql_user_completer_(
current: &std::ffi::OsStr,
) -> anyhow::Result<Vec<CompletionCandidate>> {
let server_connection =
bootstrap_server_connection_and_drop_privileges(None, None, Verbosity::new(0, 1))?;
let tokio_socket = TokioUnixStream::from_std(server_connection)?;
let mut server_connection = create_client_to_server_message_stream(tokio_socket);
while let Some(Ok(message)) = server_connection.next().await {
match message {
Response::Error(err) => {
anyhow::bail!("{}", err);
}
Response::Ready => break,
message => {
eprintln!("Unexpected message from server: {:?}", message);
}
}
}
let message = Request::CompleteUserName(current.to_string_lossy().to_string());
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::CompleteUserName(suggestions))) => suggestions,
response => return erroneous_server_response(response).map(|_| vec![]),
};
server_connection.send(Request::Exit).await?;
let result = result.into_iter().map(CompletionCandidate::new).collect();
Ok(result)
}

View File

@@ -1,4 +1,6 @@
mod check_authorization;
mod complete_database_name;
mod complete_user_name;
mod create_databases;
mod create_users;
mod drop_databases;
@@ -15,6 +17,8 @@ mod passwd_user;
mod unlock_users;
pub use check_authorization::*;
pub use complete_database_name::*;
pub use complete_user_name::*;
pub use create_databases::*;
pub use create_users::*;
pub use drop_databases::*;
@@ -64,6 +68,9 @@ pub fn create_client_to_server_message_stream(socket: UnixStream) -> ClientToSer
pub enum Request {
CheckAuthorization(CheckAuthorizationRequest),
CompleteDatabaseName(CompleteDatabaseNameRequest),
CompleteUserName(CompleteUserNameRequest),
CreateDatabases(CreateDatabasesRequest),
DropDatabases(DropDatabasesRequest),
ListDatabases(ListDatabasesRequest),
@@ -88,6 +95,9 @@ pub enum Request {
pub enum Response {
CheckAuthorization(CheckAuthorizationResponse),
CompleteDatabaseName(CompleteDatabaseNameResponse),
CompleteUserName(CompleteUserNameResponse),
// Specific data for specific commands
CreateDatabases(CreateDatabasesResponse),
DropDatabases(DropDatabasesResponse),

View File

@@ -0,0 +1,5 @@
use crate::core::types::MySQLDatabase;
pub type CompleteDatabaseNameRequest = String;
pub type CompleteDatabaseNameResponse = Vec<MySQLDatabase>;

View File

@@ -0,0 +1,5 @@
use crate::core::types::MySQLUser;
pub type CompleteUserNameRequest = String;
pub type CompleteUserNameResponse = Vec<MySQLUser>;

View File

@@ -1,4 +1,5 @@
use std::{
ffi::OsString,
fmt,
ops::{Deref, DerefMut},
str::FromStr,
@@ -49,6 +50,12 @@ impl From<String> for MySQLUser {
}
}
impl From<MySQLUser> for OsString {
fn from(val: MySQLUser) -> Self {
val.0.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct MySQLDatabase(String);
@@ -92,6 +99,12 @@ impl From<String> for MySQLDatabase {
}
}
impl From<MySQLDatabase> for OsString {
fn from(val: MySQLDatabase) -> Self {
val.0.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum DbOrUser {
Database(MySQLDatabase),

View File

@@ -3,7 +3,7 @@ extern crate prettytable;
use anyhow::Context;
use clap::{CommandFactory, Parser, ValueEnum};
use clap_complete::{Shell, generate};
use clap_complete::{CompleteEnv, Shell, generate};
use clap_verbosity_flag::Verbosity;
use std::path::PathBuf;
@@ -103,6 +103,10 @@ enum ToplevelCommands {
/// **WARNING:** This function may be run with elevated privileges.
fn main() -> anyhow::Result<()> {
if handle_dynamic_completion()?.is_some() {
return Ok(());
}
#[cfg(feature = "mysql-admutils-compatibility")]
if handle_mysql_admutils_command()?.is_some() {
return Ok(());
@@ -129,6 +133,41 @@ fn main() -> anyhow::Result<()> {
Ok(())
}
/// **WARNING:** This function may be run with elevated privileges.
fn handle_dynamic_completion() -> anyhow::Result<Option<()>> {
if std::env::var_os("COMPLETE").is_some() {
#[cfg(feature = "suid-sgid-mode")]
if executable_is_suid_or_sgid()? {
use crate::core::bootstrap::drop_privs;
drop_privs()?
}
let argv0 = std::env::args()
.next()
.and_then(|s| {
PathBuf::from(s)
.file_name()
.map(|s| s.to_string_lossy().to_string())
})
.ok_or(anyhow::anyhow!(
"Could not determine executable name for completion"
))?;
let command = match argv0.as_str() {
"muscl" => Args::command(),
"mysql-dbadm" => mysql_dbadm::Command::command(),
"mysql-useradm" => mysql_useradm::Command::command(),
command => anyhow::bail!("Unknown executable name: `{}`", command),
};
CompleteEnv::with_factory(move || command.clone()).complete();
Ok(Some(()))
} else {
Ok(None)
}
}
/// **WARNING:** This function may be run with elevated privileges.
fn handle_mysql_admutils_command() -> anyhow::Result<Option<()>> {
let argv0 = std::env::args().next().and_then(|s| {

View File

@@ -18,15 +18,16 @@ use crate::{
authorization::check_authorization,
sql::{
database_operations::{
create_databases, drop_databases, list_all_databases_for_user, list_databases,
complete_database_name, create_databases, drop_databases,
list_all_databases_for_user, list_databases,
},
database_privilege_operations::{
apply_privilege_diffs, get_all_database_privileges, get_databases_privilege_data,
},
user_operations::{
create_database_users, drop_database_users, list_all_database_users_for_unix_user,
list_database_users, lock_database_users, set_password_for_database_user,
unlock_database_users,
complete_user_name, create_database_users, drop_database_users,
list_all_database_users_for_unix_user, list_database_users, lock_database_users,
set_password_for_database_user, unlock_database_users,
},
},
},
@@ -171,6 +172,33 @@ async fn session_handler_with_db_connection(
let result = check_authorization(dbs_or_users, unix_user).await;
Response::CheckAuthorization(result)
}
Request::CompleteDatabaseName(partial_database_name) => {
// TODO: more correct validation here
if !partial_database_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Response::CompleteDatabaseName(vec![])
} else {
let result =
complete_database_name(partial_database_name, unix_user, db_connection)
.await;
Response::CompleteDatabaseName(result)
}
}
Request::CompleteUserName(partial_user_name) => {
// TODO: more correct validation here
if !partial_user_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Response::CompleteUserName(vec![])
} else {
let result =
complete_user_name(partial_user_name, unix_user, db_connection).await;
Response::CompleteUserName(result)
}
}
Request::CreateDatabases(databases_names) => {
let result = create_databases(databases_names, unix_user, db_connection).await;
Response::CreateDatabases(result)

View File

@@ -5,6 +5,7 @@ use sqlx::prelude::*;
use serde::{Deserialize, Serialize};
use crate::core::protocol::CompleteDatabaseNameResponse;
use crate::core::types::MySQLDatabase;
use crate::{
core::{
@@ -43,6 +44,45 @@ pub(super) async fn unsafe_database_exists(
Ok(result?.is_some())
}
pub async fn complete_database_name(
database_prefix: String,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
) -> CompleteDatabaseNameResponse {
let result = sqlx::query(
r#"
SELECT `SCHEMA_NAME` AS `database`
FROM `information_schema`.`SCHEMATA`
WHERE `SCHEMA_NAME` NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')
AND `SCHEMA_NAME` REGEXP ?
AND `SCHEMA_NAME` LIKE ?
"#,
)
.bind(create_user_group_matching_regex(unix_user))
.bind(format!("{}%", database_prefix))
.fetch_all(connection)
.await;
match result {
Ok(rows) => rows
.into_iter()
.filter_map(|row| {
let database: String = row.try_get("database").ok()?;
Some(database.into())
})
.collect(),
Err(err) => {
tracing::error!(
"Failed to complete database name for prefix '{}' and user '{}': {:?}",
database_prefix,
unix_user.username,
err
);
vec![]
}
}
}
pub async fn create_databases(
database_names: Vec<MySQLDatabase>,
unix_user: &UnixUser,

View File

@@ -51,6 +51,44 @@ async fn unsafe_user_exists(
result
}
pub async fn complete_user_name(
user_prefix: String,
unix_user: &UnixUser,
connection: &mut MySqlConnection,
) -> Vec<MySQLUser> {
let result = sqlx::query(
r#"
SELECT `User` AS `user`
FROM `mysql`.`user`
WHERE `User` REGEXP ?
AND `User` LIKE ?
"#,
)
.bind(create_user_group_matching_regex(unix_user))
.bind(format!("{}%", user_prefix))
.fetch_all(connection)
.await;
match result {
Ok(rows) => rows
.into_iter()
.filter_map(|row| {
let user: String = try_get_with_binary_fallback(&row, "user").ok()?;
Some(user.into())
})
.collect(),
Err(err) => {
tracing::error!(
"Failed to complete user name for prefix '{}' and user '{}': {:?}",
user_prefix,
unix_user.username,
err
);
vec![]
}
}
}
pub async fn create_database_users(
db_users: Vec<MySQLUser>,
unix_user: &UnixUser,