diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5a72f87..89a0f98f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.13.1" +version = "0.13.2" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/src/drivers/mysql/explain.rs b/src-tauri/src/drivers/mysql/explain.rs index fa555369..6f2ffa95 100644 --- a/src-tauri/src/drivers/mysql/explain.rs +++ b/src-tauri/src/drivers/mysql/explain.rs @@ -61,13 +61,22 @@ pub async fn explain_query( get_mysql_pool(params).await? }; + // Behind a bastion that rejects prepared statements, EXPLAIN variants must + // run over the text protocol (COM_QUERY) — see `super::force_text_protocol`. + let text = super::force_text_protocol(params); + // Detect server version to skip unsupported EXPLAIN variants let caps = { let mut vc = pool.acquire().await.map_err(|e| e.to_string())?; - let ver_row = sqlx::query("SELECT VERSION()") - .fetch_one(&mut *vc) - .await - .ok(); + let ver_row = if text { + use sqlx::Executor; + (&mut *vc) + .fetch_one(sqlx::raw_sql("SELECT VERSION()")) + .await + } else { + sqlx::query("SELECT VERSION()").fetch_one(&mut *vc).await + } + .ok(); let ver_str: String = ver_row.and_then(|r| r.try_get(0).ok()).unwrap_or_default(); log::debug!("MySQL/MariaDB version: {}", ver_str); parse_mysql_version(&ver_str) @@ -77,7 +86,13 @@ pub async fn explain_query( if analyze && caps.supports_explain_analyze { let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let analyze_sql = format!("EXPLAIN ANALYZE {}", query); - if let Ok(rows) = sqlx::query(&analyze_sql).fetch_all(&mut *conn).await { + let analyze_res = if text { + use sqlx::Executor; + (&mut *conn).fetch_all(sqlx::raw_sql(&analyze_sql)).await + } else { + sqlx::query(&analyze_sql).fetch_all(&mut *conn).await + }; + if let Ok(rows) = analyze_res { let mut lines = Vec::new(); for row in &rows { if let Ok(line) = row.try_get::(0) { @@ -108,7 +123,13 @@ pub async fn explain_query( if analyze && caps.supports_analyze_format { let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let maria_sql = format!("ANALYZE FORMAT=JSON {}", query); - if let Ok(row) = sqlx::query(&maria_sql).fetch_one(&mut *conn).await { + let maria_res = if text { + use sqlx::Executor; + (&mut *conn).fetch_one(sqlx::raw_sql(&maria_sql)).await + } else { + sqlx::query(&maria_sql).fetch_one(&mut *conn).await + }; + if let Ok(row) = maria_res { if let Ok(raw_json) = row.try_get::(0) { if let Ok(json_val) = serde_json::from_str::(&raw_json) { if let Some(query_block) = json_val.get("query_block") { @@ -142,10 +163,13 @@ pub async fn explain_query( let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let json_sql = format!("EXPLAIN FORMAT=JSON {}", query); let json_result: Result = async { - let row = sqlx::query(&json_sql) - .fetch_one(&mut *conn) - .await - .map_err(|e| e.to_string())?; + let row = if text { + use sqlx::Executor; + (&mut *conn).fetch_one(sqlx::raw_sql(&json_sql)).await + } else { + sqlx::query(&json_sql).fetch_one(&mut *conn).await + } + .map_err(|e| e.to_string())?; row.try_get::(0).map_err(|e| e.to_string()) } .await; @@ -174,10 +198,13 @@ pub async fn explain_query( // Tabular fallback — works on all MySQL/MariaDB versions let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let explain_sql = format!("EXPLAIN {}", query); - let rows = sqlx::query(&explain_sql) - .fetch_all(&mut *conn) - .await - .map_err(|e| e.to_string())?; + let rows = if text { + use sqlx::Executor; + (&mut *conn).fetch_all(sqlx::raw_sql(&explain_sql)).await + } else { + sqlx::query(&explain_sql).fetch_all(&mut *conn).await + } + .map_err(|e| e.to_string())?; let (root, raw) = parse_mysql_tabular_explain(&rows); Ok(ExplainPlan { diff --git a/src-tauri/src/drivers/mysql/export.rs b/src-tauri/src/drivers/mysql/export.rs index c3b1b4ba..3000f899 100644 --- a/src-tauri/src/drivers/mysql/export.rs +++ b/src-tauri/src/drivers/mysql/export.rs @@ -22,7 +22,13 @@ where F: FnMut(&[String], &[Value]) -> Result<(), String> + Send, { let pool = get_mysql_pool(params).await?; - let mut rows = sqlx::query(query).fetch(&pool); + // Behind a bastion that rejects prepared statements, stream over the text + // protocol (COM_QUERY) instead — see `super::force_text_protocol`. + let mut rows = if super::force_text_protocol(params) { + sqlx::raw_sql(query).fetch(&pool) + } else { + sqlx::query(query).fetch(&pool) + }; let mut headers: Option> = None; while let Some(row_res) = rows.next().await { diff --git a/src-tauri/src/drivers/mysql/helpers.rs b/src-tauri/src/drivers/mysql/helpers.rs index a3a101ae..2d111b2f 100644 --- a/src-tauri/src/drivers/mysql/helpers.rs +++ b/src-tauri/src/drivers/mysql/helpers.rs @@ -5,6 +5,67 @@ pub(super) fn escape_identifier(name: &str) -> String { name.replace('`', "``") } +/// Renders a `&str` as a quoted MySQL string literal for the text protocol. +/// +/// Used when a query has to bypass the prepared-statement protocol (e.g. +/// behind a Warpgate-style bastion that rejects `COM_STMT_PREPARE`): the +/// value can no longer travel as a bind parameter, so it is inlined as an +/// escaped literal instead. Mirrors `mysql_real_escape_string` for the +/// default `sql_mode` (backslash escapes enabled). +pub(super) fn mysql_string_literal(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for ch in s.chars() { + match ch { + '\0' => out.push_str("\\0"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '"' => out.push_str("\\\""), + '\u{1a}' => out.push_str("\\Z"), + c => out.push(c), + } + } + out.push('\''); + out +} + +/// Renders raw bytes as a MySQL hexadecimal literal (`x'..'`) for the text +/// protocol — the inlined equivalent of binding a `Vec` blob parameter. +pub(super) fn mysql_bytes_literal(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2 + 3); + out.push_str("x'"); + for b in bytes { + let _ = write!(out, "{:02x}", b); + } + out.push('\''); + out +} + +/// Substitutes each `?` placeholder in `sql` with the next quoted string +/// literal from `binds`, in order. Used to turn a parameterised +/// introspection query into a text-protocol statement. Placeholders past +/// the end of `binds` (and `?` chars when `binds` is empty) are left as-is. +/// +/// Note: this is only safe for the driver's own queries, whose `?` chars are +/// exclusively bind placeholders (never literal question marks in strings). +pub(super) fn inline_str_placeholders(sql: &str, binds: &[&str]) -> String { + let mut out = String::with_capacity(sql.len()); + let mut iter = binds.iter(); + for ch in sql.chars() { + if ch == '?' { + if let Some(b) = iter.next() { + out.push_str(&mysql_string_literal(b)); + continue; + } + } + out.push(ch); + } + out +} + /// Read a string from a MySQL row by index. /// MySQL 8 information_schema returns VARBINARY/BLOB instead of VARCHAR, /// so try_get:: fails silently. This falls back to reading raw bytes. diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index a01e5591..95643546 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -17,20 +17,131 @@ use crate::pool_manager::get_mysql_pool; pub use explain::explain_query; use extract::extract_value; use helpers::{ - escape_identifier, is_raw_sql_function, is_wkt_geometry, mysql_row_str, mysql_row_str_opt, + escape_identifier, inline_str_placeholders, is_raw_sql_function, is_wkt_geometry, + mysql_bytes_literal, mysql_row_str, mysql_row_str_opt, mysql_string_literal, }; use sqlx::{Column, Row}; +/// Whether this connection must avoid the prepared-statement protocol. +/// +/// Bastions like Warpgate proxy MySQL but do **not** implement +/// `COM_STMT_PREPARE`; any `sqlx::query()` (which always prepares) fails with +/// server error 1047 ("Not implemented"). The cleartext auth plugin is only +/// ever enabled to authenticate through such a bastion, so we treat it as the +/// signal to route every statement through the text protocol (`COM_QUERY` via +/// `sqlx::raw_sql`) instead. See [`crate::pool_manager::build_mysql_options`]. +pub(super) fn force_text_protocol(params: &ConnectionParams) -> bool { + params.enable_cleartext_plugin.unwrap_or(false) +} + +/// Runs a `SELECT` and returns all rows, choosing the wire protocol from +/// `text`. In text mode the `?` placeholders are inlined as escaped string +/// literals (see [`inline_str_placeholders`]); otherwise they are bound +/// through the normal prepared-statement path. `binds` are always string +/// parameters — the only kind the introspection queries use. +async fn fetch_all_rows( + pool: &sqlx::MySqlPool, + text: bool, + sql: &str, + binds: &[&str], +) -> Result, String> { + use sqlx::Executor; + if text { + let rendered = inline_str_placeholders(sql, binds); + pool.fetch_all(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_all(q).await.map_err(|e| e.to_string()) + } +} + +/// `fetch_one` variant of [`fetch_all_rows`]. +async fn fetch_one_row( + pool: &sqlx::MySqlPool, + text: bool, + sql: &str, + binds: &[&str], +) -> Result { + use sqlx::Executor; + if text { + let rendered = inline_str_placeholders(sql, binds); + pool.fetch_one(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_one(q).await.map_err(|e| e.to_string()) + } +} + +/// `fetch_optional` variant of [`fetch_all_rows`]. +async fn fetch_optional_row( + pool: &sqlx::MySqlPool, + text: bool, + sql: &str, + binds: &[&str], +) -> Result, String> { + use sqlx::Executor; + if text { + let rendered = inline_str_placeholders(sql, binds); + pool.fetch_optional(sqlx::raw_sql(&rendered)) + .await + .map_err(|e| e.to_string()) + } else { + let mut q = sqlx::query(sql); + for b in binds { + q = q.bind(*b); + } + pool.fetch_optional(q).await.map_err(|e| e.to_string()) + } +} + +/// Executes a statement that returns no result set (DDL), choosing the wire +/// protocol from `text`. Used for the bind-free `CREATE/ALTER/DROP` helpers. +async fn exec_stmt( + pool: &sqlx::MySqlPool, + text: bool, + sql: &str, +) -> Result { + use sqlx::Executor; + if text { + pool.execute(sqlx::raw_sql(sql)) + .await + .map_err(|e| e.to_string()) + } else { + pool.execute(sqlx::query(sql)) + .await + .map_err(|e| e.to_string()) + } +} + +/// Renders a primary-key value as an inlined SQL literal for the text +/// protocol: numbers stay bare, strings are quoted/escaped. Mirrors how the +/// prepared path binds the same value in a `WHERE pk = ?` clause. +fn pk_literal(pk_val: &serde_json::Value) -> Result { + match pk_val { + serde_json::Value::Number(n) => Ok(n.to_string()), + serde_json::Value::String(s) => Ok(mysql_string_literal(s)), + _ => Err("Unsupported PK type".into()), + } +} + pub async fn get_schemas(_params: &ConnectionParams) -> Result, String> { Ok(vec![]) } pub async fn get_databases(params: &ConnectionParams) -> Result, String> { let pool = get_mysql_pool(params).await?; - let rows = sqlx::query("SHOW DATABASES") - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let text = force_text_protocol(params); + let rows = fetch_all_rows(&pool, text, "SHOW DATABASES", &[]).await?; Ok(rows.iter().map(|r| mysql_row_str(r, 0)).collect()) } @@ -41,13 +152,14 @@ pub async fn get_tables( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching tables for database: {}", db_name); let pool = get_mysql_pool(params).await?; - let rows = sqlx::query( + let text = force_text_protocol(params); + let rows = fetch_all_rows( + &pool, + text, "SELECT table_name as name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name ASC", + &[db_name], ) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + .await?; let tables: Vec = rows .iter() .map(|r| TableInfo { @@ -65,6 +177,7 @@ pub async fn get_columns( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -73,12 +186,7 @@ pub async fn get_columns( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -122,6 +230,7 @@ pub async fn get_foreign_keys( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT @@ -141,12 +250,7 @@ pub async fn get_foreign_keys( ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -169,6 +273,7 @@ pub async fn get_all_columns_batch( use std::collections::HashMap; let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT table_name, column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -177,11 +282,7 @@ pub async fn get_all_columns_batch( ORDER BY table_name, ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let mut result: HashMap> = HashMap::new(); @@ -233,6 +334,7 @@ pub async fn get_all_foreign_keys_batch( use std::collections::HashMap; let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT @@ -252,11 +354,7 @@ pub async fn get_all_foreign_keys_batch( ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let mut result: HashMap> = HashMap::new(); @@ -285,6 +383,7 @@ pub async fn get_indexes( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT @@ -298,12 +397,7 @@ pub async fn get_indexes( ORDER BY INDEX_NAME, SEQ_IN_INDEX "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(table_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, table_name]).await?; Ok(rows .iter() @@ -330,27 +424,34 @@ pub async fn save_blob_column_to_file( file_path: &str, ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = format!( "SELECT `{}` FROM `{}` WHERE `{}` = ?", col_name, table, pk_col ); - let row = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - sqlx::query(&query).bind(n.as_i64()).fetch_one(&pool).await - } else if n.is_f64() { - sqlx::query(&query).bind(n.as_f64()).fetch_one(&pool).await - } else { - sqlx::query(&query) - .bind(n.to_string()) - .fetch_one(&pool) - .await + let row = if text { + use sqlx::Executor; + let q = query.replacen('?', &pk_literal(&pk_val)?, 1); + pool.fetch_one(sqlx::raw_sql(&q)).await + } else { + match pk_val { + serde_json::Value::Number(n) => { + if n.is_i64() { + sqlx::query(&query).bind(n.as_i64()).fetch_one(&pool).await + } else if n.is_f64() { + sqlx::query(&query).bind(n.as_f64()).fetch_one(&pool).await + } else { + sqlx::query(&query) + .bind(n.to_string()) + .fetch_one(&pool) + .await + } } + serde_json::Value::String(s) => sqlx::query(&query).bind(s).fetch_one(&pool).await, + _ => return Err("Unsupported PK type".into()), } - serde_json::Value::String(s) => sqlx::query(&query).bind(s).fetch_one(&pool).await, - _ => return Err("Unsupported PK type".into()), } .map_err(|e| e.to_string())?; @@ -366,27 +467,34 @@ pub async fn fetch_blob_column_as_data_url( pk_val: serde_json::Value, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = format!( "SELECT `{}` FROM `{}` WHERE `{}` = ?", col_name, table, pk_col ); - let row = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - sqlx::query(&query).bind(n.as_i64()).fetch_one(&pool).await - } else if n.is_f64() { - sqlx::query(&query).bind(n.as_f64()).fetch_one(&pool).await - } else { - sqlx::query(&query) - .bind(n.to_string()) - .fetch_one(&pool) - .await + let row = if text { + use sqlx::Executor; + let q = query.replacen('?', &pk_literal(&pk_val)?, 1); + pool.fetch_one(sqlx::raw_sql(&q)).await + } else { + match pk_val { + serde_json::Value::Number(n) => { + if n.is_i64() { + sqlx::query(&query).bind(n.as_i64()).fetch_one(&pool).await + } else if n.is_f64() { + sqlx::query(&query).bind(n.as_f64()).fetch_one(&pool).await + } else { + sqlx::query(&query) + .bind(n.to_string()) + .fetch_one(&pool) + .await + } } + serde_json::Value::String(s) => sqlx::query(&query).bind(s).fetch_one(&pool).await, + _ => return Err("Unsupported PK type".into()), } - serde_json::Value::String(s) => sqlx::query(&query).bind(s).fetch_one(&pool).await, - _ => return Err("Unsupported PK type".into()), } .map_err(|e| e.to_string())?; @@ -401,21 +509,28 @@ pub async fn delete_record( pk_val: serde_json::Value, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = format!("DELETE FROM `{}` WHERE `{}` = ?", table, pk_col); - let result = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - sqlx::query(&query).bind(n.as_i64()).execute(&pool).await - } else if n.is_f64() { - sqlx::query(&query).bind(n.as_f64()).execute(&pool).await - } else { - sqlx::query(&query).bind(n.to_string()).execute(&pool).await + let result = if text { + use sqlx::Executor; + let q = query.replacen('?', &pk_literal(&pk_val)?, 1); + pool.execute(sqlx::raw_sql(&q)).await + } else { + match pk_val { + serde_json::Value::Number(n) => { + if n.is_i64() { + sqlx::query(&query).bind(n.as_i64()).execute(&pool).await + } else if n.is_f64() { + sqlx::query(&query).bind(n.as_f64()).execute(&pool).await + } else { + sqlx::query(&query).bind(n.to_string()).execute(&pool).await + } } + serde_json::Value::String(s) => sqlx::query(&query).bind(s).execute(&pool).await, + _ => return Err("Unsupported PK type".into()), } - serde_json::Value::String(s) => sqlx::query(&query).bind(s).execute(&pool).await, - _ => return Err("Unsupported PK type".into()), }; result.map(|r| r.rows_affected()).map_err(|e| e.to_string()) @@ -431,13 +546,22 @@ pub async fn update_record( max_blob_size: u64, ) -> Result { let pool = get_mysql_pool(params).await?; + // Behind a prepared-statement-less bastion every value is inlined as an + // escaped literal instead of bound (see `force_text_protocol`). + let text = force_text_protocol(params); let mut qb = sqlx::QueryBuilder::new(format!("UPDATE `{}` SET `{}` = ", table, col_name)); match new_val { serde_json::Value::Number(n) => { if n.is_i64() { - qb.push_bind(n.as_i64()); + if text { + qb.push(n.to_string()); + } else { + qb.push_bind(n.as_i64()); + } + } else if text { + qb.push(n.to_string()); } else { qb.push_bind(n.as_f64()); } @@ -451,7 +575,11 @@ pub async fn update_record( { // Blob wire format: decode to raw bytes so the DB stores binary data, // not the internal wire format string. - qb.push_bind(bytes); + if text { + qb.push(mysql_bytes_literal(&bytes)); + } else { + qb.push_bind(bytes); + } } else if is_raw_sql_function(&s) { // If it's a raw SQL function (e.g., ST_GeomFromText('POINT(1 2)', 4326)) // insert it directly without parameter binding @@ -459,19 +587,33 @@ pub async fn update_record( } else if is_wkt_geometry(&s) { // If it's WKT geometry format, wrap with ST_GeomFromText qb.push("ST_GeomFromText("); - qb.push_bind(s); + if text { + qb.push(mysql_string_literal(&s)); + } else { + qb.push_bind(s); + } qb.push(")"); } else if let Some(n) = parse_unsafe_bigint_string(&s) { // Bigints outside JS safe range come back from the UI as strings // (see drivers::common::i64_to_json). Bind them as native i64 so // BIGINT columns receive the exact value. - qb.push_bind(n); + if text { + qb.push(n.to_string()); + } else { + qb.push_bind(n); + } + } else if text { + qb.push(mysql_string_literal(&s)); } else { qb.push_bind(s); } } serde_json::Value::Bool(b) => { - qb.push_bind(b); + if text { + qb.push(if b { "1" } else { "0" }); + } else { + qb.push_bind(b); + } } serde_json::Value::Null => { qb.push("NULL"); @@ -479,7 +621,11 @@ pub async fn update_record( serde_json::Value::Object(_) | serde_json::Value::Array(_) => { let json_str = serde_json::to_string(&new_val).map_err(|e| e.to_string())?; qb.push("CAST("); - qb.push_bind(json_str); + if text { + qb.push(mysql_string_literal(&json_str)); + } else { + qb.push_bind(json_str); + } qb.push(" AS JSON)"); } } @@ -489,14 +635,26 @@ pub async fn update_record( match pk_val { serde_json::Value::Number(n) => { if n.is_i64() { - qb.push_bind(n.as_i64()); + if text { + qb.push(n.to_string()); + } else { + qb.push_bind(n.as_i64()); + } + } else if text { + qb.push(n.to_string()); } else { qb.push_bind(n.as_f64()); } } serde_json::Value::String(s) => { if let Some(n) = parse_unsafe_bigint_string(&s) { - qb.push_bind(n); + if text { + qb.push(n.to_string()); + } else { + qb.push_bind(n); + } + } else if text { + qb.push(mysql_string_literal(&s)); } else { qb.push_bind(s); } @@ -504,8 +662,11 @@ pub async fn update_record( _ => return Err("Unsupported PK type".into()), } - let query = qb.build(); - let result = query.execute(&pool).await.map_err(|e| e.to_string())?; + let result = if text { + exec_stmt(&pool, true, &qb.into_sql()).await? + } else { + qb.build().execute(&pool).await.map_err(|e| e.to_string())? + }; Ok(result.rows_affected()) } @@ -516,6 +677,9 @@ pub async fn insert_record( max_blob_size: u64, ) -> Result { let pool = get_mysql_pool(params).await?; + // Behind a prepared-statement-less bastion every value is inlined as an + // escaped literal instead of bound (see `force_text_protocol`). + let text = force_text_protocol(params); let mut cols = Vec::new(); let mut vals = Vec::new(); @@ -540,7 +704,13 @@ pub async fn insert_record( match val { serde_json::Value::Number(n) => { if n.is_i64() { - separated.push_bind(n.as_i64()); + if text { + separated.push(n.to_string()); + } else { + separated.push_bind(n.as_i64()); + } + } else if text { + separated.push(n.to_string()); } else { separated.push_bind(n.as_f64()); } @@ -551,7 +721,11 @@ pub async fn insert_record( { // Blob wire format: decode to raw bytes so the DB stores binary data, // not the internal wire format string. - separated.push_bind(bytes); + if text { + separated.push(mysql_bytes_literal(&bytes)); + } else { + separated.push_bind(bytes); + } } else if is_raw_sql_function(&s) { // If it's a raw SQL function (e.g., ST_GeomFromText('POINT(1 2)', 4326)) // insert it directly without parameter binding @@ -559,16 +733,30 @@ pub async fn insert_record( } else if is_wkt_geometry(&s) { // If it's WKT geometry format, wrap with ST_GeomFromText separated.push_unseparated("ST_GeomFromText("); - separated.push_bind_unseparated(s); + if text { + separated.push_unseparated(mysql_string_literal(&s)); + } else { + separated.push_bind_unseparated(s); + } separated.push_unseparated(")"); } else if let Some(n) = parse_unsafe_bigint_string(&s) { - separated.push_bind(n); + if text { + separated.push(n.to_string()); + } else { + separated.push_bind(n); + } + } else if text { + separated.push(mysql_string_literal(&s)); } else { separated.push_bind(s); } } serde_json::Value::Bool(b) => { - separated.push_bind(b); + if text { + separated.push(if b { "1" } else { "0" }); + } else { + separated.push_bind(b); + } } serde_json::Value::Null => { separated.push("NULL"); @@ -576,7 +764,11 @@ pub async fn insert_record( serde_json::Value::Object(_) | serde_json::Value::Array(_) => { let json_str = serde_json::to_string(&val).map_err(|e| e.to_string())?; separated.push_unseparated("CAST("); - separated.push_bind_unseparated(json_str); + if text { + separated.push_unseparated(mysql_string_literal(&json_str)); + } else { + separated.push_bind_unseparated(json_str); + } separated.push_unseparated(" AS JSON)"); } } @@ -585,18 +777,19 @@ pub async fn insert_record( qb }; - let query = qb.build(); - let result = query.execute(&pool).await.map_err(|e| e.to_string())?; + let result = if text { + exec_stmt(&pool, true, &qb.into_sql()).await? + } else { + qb.build().execute(&pool).await.map_err(|e| e.to_string())? + }; Ok(result.rows_affected()) } pub async fn get_table_ddl(params: &ConnectionParams, table_name: &str) -> Result { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = format!("SHOW CREATE TABLE `{}`", table_name); - let row = sqlx::query(&query) - .fetch_one(&pool) - .await - .map_err(|e| e.to_string())?; + let row = fetch_one_row(&pool, text, &query, &[]).await?; let create_sql = mysql_row_str(&row, 1); Ok(format!("{};", create_sql)) @@ -609,13 +802,14 @@ pub async fn get_views( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching views for database: {}", db_name); let pool = get_mysql_pool(params).await?; - let rows = sqlx::query( - "SELECT table_name as name FROM information_schema.views WHERE table_schema = ? ORDER BY table_name ASC", - ) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let text = force_text_protocol(params); + let rows = fetch_all_rows( + &pool, + text, + "SELECT table_name as name FROM information_schema.views WHERE table_schema = ? ORDER BY table_name ASC", + &[db_name], + ) + .await?; let views: Vec = rows .iter() .map(|r| ViewInfo { @@ -632,10 +826,10 @@ pub async fn get_view_definition( view_name: &str, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let escaped_name = escape_identifier(view_name); let query = format!("SHOW CREATE VIEW `{}`", escaped_name); - let row = sqlx::query(&query) - .fetch_one(&pool) + let row = fetch_one_row(&pool, text, &query, &[]) .await .map_err(|e| format!("Failed to get view definition: {}", e))?; let definition = mysql_row_str(&row, 1); @@ -649,10 +843,10 @@ pub async fn create_view( definition: &str, ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let escaped_name = escape_identifier(view_name); let query = format!("CREATE VIEW `{}` AS {}", escaped_name, definition); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to create view: {}", e))?; Ok(()) @@ -664,10 +858,10 @@ pub async fn alter_view( definition: &str, ) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let escaped_name = escape_identifier(view_name); let query = format!("ALTER VIEW `{}` AS {}", escaped_name, definition); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to alter view: {}", e))?; Ok(()) @@ -675,10 +869,10 @@ pub async fn alter_view( pub async fn drop_view(params: &ConnectionParams, view_name: &str) -> Result<(), String> { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let escaped_name = escape_identifier(view_name); let query = format!("DROP VIEW IF EXISTS `{}`", escaped_name); - sqlx::query(&query) - .execute(&pool) + exec_stmt(&pool, text, &query) .await .map_err(|e| format!("Failed to drop view: {}", e))?; Ok(()) @@ -692,6 +886,7 @@ pub async fn get_view_columns( let db_name = schema.unwrap_or_else(|| params.database.primary()); // Views in MySQL can be queried like tables for column info let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT column_name, data_type, column_key, is_nullable, extra, column_default, character_maximum_length @@ -700,12 +895,7 @@ pub async fn get_view_columns( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(view_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, view_name]).await?; Ok(rows .iter() @@ -748,6 +938,7 @@ pub async fn get_routines( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT routine_name, routine_type, routine_definition FROM information_schema.routines @@ -755,11 +946,7 @@ pub async fn get_routines( ORDER BY routine_name "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; Ok(rows .iter() @@ -778,6 +965,7 @@ pub async fn get_routine_parameters( ) -> Result, String> { let db_name = schema.unwrap_or_else(|| params.database.primary()); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); // 1. Get return type for functions from routines table let return_type_query = r#" @@ -786,12 +974,8 @@ pub async fn get_routine_parameters( WHERE ROUTINE_SCHEMA = ? AND ROUTINE_NAME = ? "#; - let routine_info = sqlx::query(return_type_query) - .bind(db_name) - .bind(routine_name) - .fetch_optional(&pool) - .await - .map_err(|e| e.to_string())?; + let routine_info = + fetch_optional_row(&pool, text, return_type_query, &[db_name, routine_name]).await?; let mut parameters = Vec::new(); @@ -818,12 +1002,7 @@ pub async fn get_routine_parameters( ORDER BY ordinal_position "#; - let rows = sqlx::query(query) - .bind(db_name) - .bind(routine_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name, routine_name]).await?; parameters.extend(rows.iter().map(|r| RoutineParameter { name: mysql_row_str(r, 0), @@ -841,16 +1020,14 @@ pub async fn get_routine_definition( routine_type: &str, ) -> Result { let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = format!( "SHOW CREATE {} `{}`", routine_type, escape_identifier(routine_name) ); - let row = sqlx::query(&query) - .fetch_one(&pool) - .await - .map_err(|e| e.to_string())?; + let row = fetch_one_row(&pool, text, &query, &[]).await?; let definition = mysql_row_str(&row, 2); @@ -903,6 +1080,7 @@ async fn exec_on_mysql_conn( query: &str, limit: Option, page: u32, + text: bool, ) -> Result { // Transaction-control statements have to bypass the prepared-statement // protocol — see `is_text_protocol_stmt`. They never return a result @@ -923,13 +1101,16 @@ async fn exec_on_mysql_conn( } // Non-result-set statements (INSERT / UPDATE / DELETE / DDL) go through - // `execute()` so we can return the actual `rows_affected`. + // `execute()` so we can return the actual `rows_affected`. In text mode + // they must use `raw_sql` (COM_QUERY) since the bastion rejects prepares. if !crate::drivers::common::returns_result_set(query) { use sqlx::Executor; - let exec_result = conn - .execute(sqlx::query(query)) - .await - .map_err(|e| e.to_string())?; + let exec_result = if text { + conn.execute(sqlx::raw_sql(query)).await + } else { + conn.execute(sqlx::query(query)).await + } + .map_err(|e| e.to_string())?; return Ok(QueryResult { columns: vec![], rows: vec![], @@ -968,7 +1149,12 @@ async fn exec_on_mysql_conn( // Scope the stream so `conn` borrow is released before returning { use futures::stream::StreamExt; - let mut rows_stream = sqlx::query(&final_query).fetch(&mut *conn); + let mut rows_stream = if text { + use sqlx::Executor; + (&mut *conn).fetch(sqlx::raw_sql(&final_query)) + } else { + sqlx::query(&final_query).fetch(&mut *conn) + }; while let Some(result) = rows_stream.next().await { match result { @@ -1026,7 +1212,8 @@ pub async fn execute_query( schema: Option<&str>, ) -> Result { let mut conn = acquire_mysql_conn(params, schema).await?; - exec_on_mysql_conn(&mut *conn, query, limit, page).await + let text = force_text_protocol(params); + exec_on_mysql_conn(&mut *conn, query, limit, page, text).await } /// Runs a sequence of statements on a single pooled connection so that @@ -1047,10 +1234,11 @@ pub async fn execute_batch( on_progress: Option<&crate::drivers::driver_trait::BatchProgressFn>, ) -> Result, String> { let mut conn = acquire_mysql_conn(params, schema).await?; + let text = force_text_protocol(params); let mut results = Vec::with_capacity(queries.len()); for (idx, q) in queries.iter().enumerate() { let start = std::time::Instant::now(); - let outcome = exec_on_mysql_conn(&mut *conn, q, limit, page).await; + let outcome = exec_on_mysql_conn(&mut *conn, q, limit, page, text).await; let res = crate::models::BatchStatementResult::from_outcome(start, outcome); if let Some(cb) = on_progress { cb(idx, &res); @@ -1067,17 +1255,14 @@ pub async fn get_triggers( let db_name = schema.unwrap_or_else(|| params.database.primary()); log::debug!("MySQL: Fetching triggers for database: {}", db_name); let pool = get_mysql_pool(params).await?; + let text = force_text_protocol(params); let query = r#" SELECT trigger_name, event_object_table, event_manipulation, action_timing FROM information_schema.triggers WHERE trigger_schema = ? ORDER BY trigger_name ASC "#; - let rows = sqlx::query(query) - .bind(db_name) - .fetch_all(&pool) - .await - .map_err(|e| e.to_string())?; + let rows = fetch_all_rows(&pool, text, query, &[db_name]).await?; let triggers: Vec = rows .iter() .map(|r| TriggerInfo { @@ -1103,8 +1288,8 @@ pub async fn get_trigger_definition( None => format!("`{}`", escape_identifier(trigger_name)), }; let query = format!("SHOW CREATE TRIGGER {}", qualified); - let row = sqlx::query(&query) - .fetch_one(&pool) + let text = force_text_protocol(params); + let row = fetch_one_row(&pool, text, &query, &[]) .await .map_err(|e| format!("Failed to get trigger definition: {}", e))?; // SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ... @@ -1339,6 +1524,22 @@ impl DatabaseDriver for MysqlDriver { )) } + async fn test_connection( + &self, + params: &crate::models::ConnectionParams, + ) -> Result<(), String> { + // Use the same option builder as the live pool so TLS settings and the + // cleartext plugin gating (mysql_clear_password) apply to the test too. + let options = crate::pool_manager::build_mysql_options(params, None)?; + let mut conn = + ::connect(&options) + .await + .map_err(|e: sqlx::Error| e.to_string())?; + sqlx::Connection::ping(&mut conn) + .await + .map_err(|e: sqlx::Error| e.to_string()) + } + async fn ping(&self, params: &crate::models::ConnectionParams) -> Result<(), String> { let conn_id = params.connection_id.as_deref(); if !crate::pool_manager::has_pool(params, conn_id).await { diff --git a/src-tauri/src/drivers/mysql/tests.rs b/src-tauri/src/drivers/mysql/tests.rs index e26dc835..116e13c9 100644 --- a/src-tauri/src/drivers/mysql/tests.rs +++ b/src-tauri/src/drivers/mysql/tests.rs @@ -1,4 +1,5 @@ use super::explain::{parse_analyze_actual, parse_mysql_analyze_text, parse_mysql_query_block}; +use super::helpers::{inline_str_placeholders, mysql_bytes_literal, mysql_string_literal}; use super::MysqlDriver; use crate::drivers::driver_trait::DatabaseDriver; use crate::models::ExplainNode; @@ -36,6 +37,55 @@ fn build_connection_url_includes_disabled_ssl_mode() { assert!(url.contains("ssl-mode=disabled"), "url was: {url}"); } +// -- Text-protocol literal helpers (Warpgate / cleartext bastion path) ----- + +#[test] +fn mysql_string_literal_quotes_and_escapes() { + assert_eq!(mysql_string_literal("public"), "'public'"); + assert_eq!(mysql_string_literal("o'brien"), "'o\\'brien'"); + assert_eq!(mysql_string_literal("a\\b"), "'a\\\\b'"); + assert_eq!(mysql_string_literal("line\nbreak"), "'line\\nbreak'"); + assert_eq!(mysql_string_literal(""), "''"); +} + +#[test] +fn mysql_bytes_literal_hex_encodes() { + assert_eq!(mysql_bytes_literal(&[]), "x''"); + assert_eq!(mysql_bytes_literal(&[0x00, 0x0f, 0xff]), "x'000fff'"); + assert_eq!(mysql_bytes_literal(b"AB"), "x'4142'"); +} + +#[test] +fn inline_str_placeholders_substitutes_in_order() { + let sql = "WHERE table_schema = ? AND table_name = ?"; + assert_eq!( + inline_str_placeholders(sql, &["mydb", "users"]), + "WHERE table_schema = 'mydb' AND table_name = 'users'" + ); +} + +#[test] +fn inline_str_placeholders_escapes_injection_attempt() { + let sql = "WHERE table_schema = ?"; + assert_eq!( + inline_str_placeholders(sql, &["x' OR '1'='1"]), + "WHERE table_schema = 'x\\' OR \\'1\\'=\\'1'" + ); +} + +#[test] +fn inline_str_placeholders_leaves_extra_placeholders() { + // Fewer binds than placeholders: the surplus `?` stays untouched. + assert_eq!( + inline_str_placeholders("a = ? AND b = ?", &["1"]), + "a = '1' AND b = ?" + ); + assert_eq!( + inline_str_placeholders("no params here", &[]), + "no params here" + ); +} + /// Helper: parse a MariaDB ANALYZE FORMAT=JSON string and return the root node. fn parse_json(json: &str) -> ExplainNode { let val: serde_json::Value = serde_json::from_str(json).expect("invalid JSON"); @@ -572,8 +622,7 @@ fn parse_analyze_actual_multiplies_per_loop_time_by_loops() { #[test] fn parse_analyze_actual_single_loop_is_unchanged() { - let (time_ms, _, loops) = - parse_analyze_actual(" (actual time=0.10..0.42 rows=5 loops=1)"); + let (time_ms, _, loops) = parse_analyze_actual(" (actual time=0.10..0.42 rows=5 loops=1)"); assert_eq!(loops, Some(1)); assert!((time_ms.unwrap() - 0.42).abs() < 1e-9); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eba9d080..1f415ce9 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -125,6 +125,10 @@ pub struct ConnectionParams { pub ssl_ca: Option, pub ssl_cert: Option, pub ssl_key: Option, + // MySQL/MariaDB: enable the mysql_clear_password (cleartext) auth plugin. + // Required by bastions like Warpgate. Only honoured over a TLS connection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_cleartext_plugin: Option, // SSH Tunnel pub ssh_enabled: Option, pub ssh_connection_id: Option, diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 12351aaa..d59cc2d4 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -85,11 +85,14 @@ pub(crate) fn build_connection_key( ) -> String { let tls_key = match params.driver.as_str() { "mysql" => Some(format!( - "ssl:{}:{}:{}:{}", + // `clear` keeps cleartext and non-cleartext connections to the same + // host in separate pools — they authenticate differently. + "ssl:{}:{}:{}:{}:clear:{}", params.ssl_mode.as_deref().unwrap_or("default"), params.ssl_ca.as_deref().unwrap_or(""), params.ssl_cert.as_deref().unwrap_or(""), - params.ssl_key.as_deref().unwrap_or("") + params.ssl_key.as_deref().unwrap_or(""), + params.enable_cleartext_plugin.unwrap_or(false) )), "postgres" => { let ssl_mode = params.ssl_mode.as_deref().unwrap_or("prefer"); @@ -106,12 +109,17 @@ pub(crate) fn build_connection_key( // Include database in key so different databases on the same connection use separate pools format!("{}:conn:{}:{}", params.driver, conn_id, params.database) } else { - // Fall back to host:port:database for ad-hoc connections + // Fall back to host:port:user:database for ad-hoc connections (no saved + // id). The username is essential: bastions like Warpgate multiplex many + // targets behind a single host:port and pick the backend from the + // username, so without it two different targets would share one pool and + // serve each other's databases. format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", params.driver, params.host.as_deref().unwrap_or("localhost"), params.port.unwrap_or(0), + params.username.as_deref().unwrap_or(""), params.database ) }; @@ -169,6 +177,20 @@ pub(crate) fn build_mysql_options( options = options.ssl_client_key(key); } + // Optionally enable the mysql_clear_password (cleartext) auth plugin, used by + // bastions like Warpgate. Cleartext credentials must never be sent over an + // unencrypted link, so refuse to enable it when TLS is disabled. + if params.enable_cleartext_plugin.unwrap_or(false) { + if matches!(ssl_mode, MySqlSslMode::Disabled) { + return Err("Cleartext password plugin requires a TLS/SSL mode to be \ + enabled (Preferred, Required, Verify CA, or Verify Identity). \ + Refusing to send the password in cleartext over an unencrypted \ + connection." + .to_string()); + } + options = options.enable_cleartext_plugin(true); + } + Ok(options) } diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 6fb700f2..7f59907e 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -135,6 +135,51 @@ mod tests { MySqlSslMode::VerifyIdentity )); } + + #[test] + fn adhoc_mysql_pool_key_changes_when_username_changes() { + // No connection_id → ad-hoc key. Bastions like Warpgate share one + // host:port across targets and select the backend by username, so two + // usernames must never resolve to the same pool. + let mut alice = mysql_params("required"); + alice.username = Some("alice".to_string()); + let mut bob = mysql_params("required"); + bob.username = Some("bob".to_string()); + + assert_ne!( + build_connection_key(&alice, None), + build_connection_key(&bob, None) + ); + } + + #[test] + fn mysql_pool_key_changes_when_cleartext_plugin_changes() { + let mut plain = mysql_params("required"); + plain.enable_cleartext_plugin = Some(false); + let mut cleartext = mysql_params("required"); + cleartext.enable_cleartext_plugin = Some(true); + + assert_ne!( + build_connection_key(&plain, Some("conn-1")), + build_connection_key(&cleartext, Some("conn-1")) + ); + } + + #[test] + fn cleartext_plugin_rejected_without_tls() { + let mut params = mysql_params("disabled"); + params.enable_cleartext_plugin = Some(true); + + assert!(build_mysql_options(¶ms, None).is_err()); + } + + #[test] + fn cleartext_plugin_allowed_with_tls() { + let mut params = mysql_params("required"); + params.enable_cleartext_plugin = Some(true); + + assert!(build_mysql_options(¶ms, None).is_ok()); + } } #[cfg(test)] diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 093a2d2e..6b252397 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -57,6 +57,8 @@ interface ConnectionParams { ssl_ca?: string; ssl_cert?: string; ssl_key?: string; + // MySQL/MariaDB: mysql_clear_password (cleartext) auth plugin (TLS required) + enable_cleartext_plugin?: boolean; // SSH ssh_enabled?: boolean; ssh_connection_id?: string; @@ -1315,7 +1317,13 @@ export const NewConnectionModal = ({ verify_identity: t("newConnection.sslModes.verify_identity", { defaultValue: "Verify Identity" }), } } - onChange={(v) => updateField("ssl_mode", v)} + onChange={(v) => { + updateField("ssl_mode", v); + // Cleartext auth must never go over an unencrypted link. + if (driver === "mysql" && v === "disabled") { + updateField("enable_cleartext_plugin", false); + } + }} searchable={false} /> @@ -1444,6 +1452,50 @@ export const NewConnectionModal = ({ )} + + {/* Cleartext password plugin (MySQL/MariaDB only) */} + {driver === "mysql" && + (() => { + const effectiveSslMode = formData.ssl_mode || "required"; + const tlsOff = effectiveSslMode === "disabled"; + return ( +
+ +

+ {tlsOff + ? t("newConnection.enableCleartextPluginTlsRequired", { + defaultValue: + "Enable an SSL mode above to use the cleartext password plugin.", + }) + : t("newConnection.enableCleartextPluginHint", { + defaultValue: + "Sends the password using mysql_clear_password. Required for bastions like Warpgate. Only used over a TLS connection.", + })} +

+
+ ); + })()} ); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f02407c4..be6c759e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -662,6 +662,9 @@ "manageSshConnections": "SSH-Verbindungen verwalten", "noSshConnections": "Keine SSH-Verbindungen verfügbar", "sslMode": "SSL-Modus", + "enableCleartextPlugin": "Klartext-Passwort-Plugin aktivieren", + "enableCleartextPluginHint": "Sendet das Passwort über mysql_clear_password. Erforderlich für Bastions wie Warpgate. Wird nur über eine TLS-Verbindung verwendet.", + "enableCleartextPluginTlsRequired": "Aktivieren Sie oben einen SSL-Modus, um das Klartext-Passwort-Plugin zu verwenden.", "sslModes": { "disable": "Deaktivieren", "allow": "Erlauben", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 47b30cab..949d88d0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -683,6 +683,9 @@ "manageSshConnections": "Manage SSH Connections", "noSshConnections": "No SSH connections available", "sslMode": "SSL Mode", + "enableCleartextPlugin": "Enable cleartext password plugin", + "enableCleartextPluginHint": "Sends the password using mysql_clear_password. Required for bastions like Warpgate. Only used over a TLS connection.", + "enableCleartextPluginTlsRequired": "Enable an SSL mode above to use the cleartext password plugin.", "sslModes": { "disable": "Disable", "allow": "Allow", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a4c4b600..43dfdcb5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -667,6 +667,9 @@ "manageSshConnections": "Gestionar Conexiones SSH", "noSshConnections": "No hay conexiones SSH disponibles", "sslMode": "Modo SSL", + "enableCleartextPlugin": "Habilitar el complemento de contraseña en texto plano", + "enableCleartextPluginHint": "Envía la contraseña usando mysql_clear_password. Necesario para bastiones como Warpgate. Solo se usa en una conexión TLS.", + "enableCleartextPluginTlsRequired": "Habilita un modo SSL arriba para usar el complemento de contraseña en texto plano.", "sslModes": { "disable": "Desactivado", "allow": "Permitir", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index acd8c9da..295bfedd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -662,6 +662,9 @@ "manageSshConnections": "Gérer les connexions SSH", "noSshConnections": "Aucune connexion SSH disponible", "sslMode": "Mode SSL", + "enableCleartextPlugin": "Activer le plugin de mot de passe en clair", + "enableCleartextPluginHint": "Envoie le mot de passe via mysql_clear_password. Requis pour les bastions comme Warpgate. Utilisé uniquement sur une connexion TLS.", + "enableCleartextPluginTlsRequired": "Activez un mode SSL ci-dessus pour utiliser le plugin de mot de passe en clair.", "sslModes": { "disable": "Désactiver", "allow": "Autoriser", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bc178b56..f6a7c9fd 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -667,6 +667,9 @@ "manageSshConnections": "Gestisci Connessioni SSH", "noSshConnections": "Nessuna connessione SSH disponibile", "sslMode": "Modalità SSL", + "enableCleartextPlugin": "Abilita il plugin password in chiaro", + "enableCleartextPluginHint": "Invia la password tramite mysql_clear_password. Richiesto per bastion come Warpgate. Usato solo su una connessione TLS.", + "enableCleartextPluginTlsRequired": "Abilita una modalità SSL sopra per usare il plugin password in chiaro.", "sslModes": { "disable": "Disabilitato", "allow": "Permetti", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 313fb88b..e599828f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -676,6 +676,9 @@ "manageSshConnections": "SSH 接続を管理", "noSshConnections": "利用可能な SSH 接続がありません", "sslMode": "SSL モード", + "enableCleartextPlugin": "クリアテキストパスワードプラグインを有効にする", + "enableCleartextPluginHint": "mysql_clear_password を使用してパスワードを送信します。Warpgate などのバスティオンで必要です。TLS 接続でのみ使用されます。", + "enableCleartextPluginTlsRequired": "クリアテキストパスワードプラグインを使用するには、上で SSL モードを有効にしてください。", "sslModes": { "disable": "無効", "allow": "許可", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3d2f3520..4fe5e7ad 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -656,6 +656,9 @@ "manageSshConnections": "Управление SSH-подключениями", "noSshConnections": "Нет доступных SSH-подключений", "sslMode": "Режим SSL", + "enableCleartextPlugin": "Включить плагин пароля в открытом виде", + "enableCleartextPluginHint": "Отправляет пароль через mysql_clear_password. Требуется для бастионов, таких как Warpgate. Используется только при TLS-соединении.", + "enableCleartextPluginTlsRequired": "Включите режим SSL выше, чтобы использовать плагин пароля в открытом виде.", "sslModes": { "disable": "Отключён", "allow": "Разрешён", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ac0a38a1..01064b70 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -630,6 +630,9 @@ "manageSshConnections": "管理 SSH 连接", "noSshConnections": "无 SSH 连接可用", "sslMode": "SSL 模式", + "enableCleartextPlugin": "启用明文密码插件", + "enableCleartextPluginHint": "使用 mysql_clear_password 发送密码。Warpgate 等堡垒机需要此选项。仅在 TLS 连接上使用。", + "enableCleartextPluginTlsRequired": "请在上方启用 SSL 模式以使用明文密码插件。", "sslModes": { "disable": "禁用", "allow": "允许",