Skip to main content

netget/server/mysql/
actions.rs

1//! MySQL protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::{Arc, LazyLock};
14use tokio::sync::mpsc;
15use tracing::debug;
16
17/// MySQL protocol action handler
18pub struct MysqlProtocol {
19    _connection_id: ConnectionId,
20    _app_state: Arc<AppState>,
21    status_tx: mpsc::UnboundedSender<String>,
22}
23
24impl MysqlProtocol {
25    pub fn new(
26        connection_id: ConnectionId,
27        app_state: Arc<AppState>,
28        status_tx: mpsc::UnboundedSender<String>,
29    ) -> Self {
30        Self {
31            _connection_id: connection_id,
32            _app_state: app_state,
33            status_tx,
34        }
35    }
36}
37
38// Implement Protocol trait (common functionality)
39impl Protocol for MysqlProtocol {
40    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
41        vec![
42                crate::llm::actions::ParameterDefinition {
43                    name: "send_first".to_string(),
44                    type_hint: "boolean".to_string(),
45                    description: "Unsupported by this server and refused if set to true. MySQL: the server already sends its initial handshake packet unconditionally, because the protocol requires it - there is nothing for this flag to turn on."
46                        .to_string(),
47                    required: false,
48                    example: serde_json::json!(false),
49                    default: None,
50                },
51            ]
52    }
53    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
54        // No user-triggered actions. (A `list_mysql_connections` action used to be declared
55        // here; its executor returned a hardcoded empty list, so it only ever misled the model.)
56        vec![]
57    }
58    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
59        vec![
60            mysql_query_response_action(),
61            mysql_error_response_action(),
62            mysql_ok_response_action(),
63            close_this_connection_action(),
64        ]
65    }
66    fn protocol_name(&self) -> &'static str {
67        "MySQL"
68    }
69    fn get_event_types(&self) -> Vec<EventType> {
70        get_mysql_event_types()
71    }
72    fn stack_name(&self) -> &'static str {
73        "ETH>IP>TCP>MySQL"
74    }
75    fn keywords(&self) -> Vec<&'static str> {
76        vec!["mysql"]
77    }
78    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
79        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
80
81        ProtocolMetadataV2::builder()
82            // Beta: exercised against two independent clients — mysql_async and the real
83            // `mysql` CLI — covering handshake, queries, errors and prepared statements.
84            // Not Stable: the Stable bar additionally wants a green pcap oracle, a fuzz
85            // target with a depth-bomb corpus, and a test per declared bound, none of which
86            // exist here.
87            .state(DevelopmentState::Beta)
88            .well_known_port(3306)
89            .implementation("opensrv-mysql v0.7 protocol library")
90            // The number this server publishes and the number it enforces are one number:
91            // opensrv-mysql answers `SELECT @@max_allowed_packet` with 67108864 itself,
92            // without consulting this shim, and until `packet_limit` existed nothing was
93            // ever compared against it.
94            .max_inbound_bytes(crate::server::mysql::packet_limit::MAX_PACKET_BYTES)
95            .llm_control("Query responses (result sets, OK packets, ERR packets)")
96            .e2e_testing(
97                "Two independent clients, neither skipped nor #[ignore]d. (1) the mysql_async \
98                 crate, text and binary (prepared) protocols \
99                 (tests/server/mysql/test.rs, prepared_statement_test.rs). (2) the real \
100                 `mysql` CLI, 9.3.0 (tests/server/mysql/real_client_test.rs): connect, a \
101                 SELECT whose rows the model authors, and a statement answered with an ERR \
102                 packet, asserted on what the client printed — including the \
103                 caching_sha2_password fast-auth branch a client that was given a password \
104                 takes. That test fails rather than skips when the binary is absent. Until \
105                 September 2026 the CLI could not connect at all ('Authentication plugin \
106                 mysql_native_password cannot be loaded'), so the rating rested on \
107                 mysql_async alone being more permissive than the client a user would reach \
108                 for.",
109            )
110            .notes(
111                "THIS SERVER AUTHENTICATES NOTHING. It offers caching_sha2_password so a \
112                 current client can complete the connection phase, and then accepts whatever \
113                 that client sent: no password is stored or compared, the model is not asked, \
114                 the nonce is a fixed string, and mysql_native_password is still accepted \
115                 too. 'Offers caching_sha2_password' is a statement about the packets, not \
116                 about security. No TLS either. Prepared statements work and report their `?` \
117                 count, but parameter values are not substituted - the model sees the `?`.",
118            )
119            .build()
120    }
121    fn description(&self) -> &'static str {
122        "MySQL database server"
123    }
124    fn example_prompt(&self) -> &'static str {
125        "Start a MySQL server on port 3306"
126    }
127    fn group_name(&self) -> &'static str {
128        "Database"
129    }
130
131    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
132        use crate::llm::actions::StartupExamples;
133        use serde_json::json;
134
135        // Deterministic answers for a couple of well-known queries, falling back
136        // to an empty OK for writes. Real column/row shapes, no LLM call.
137        let script = r#"import json, sys
138data = json.load(sys.stdin)
139event = data["event"]
140if data["event_type_id"] == "mysql_query":
141    q = event.get("query", "").strip().rstrip(";").upper()
142    if q.startswith("SELECT VERSION()"):
143        actions = [{"type": "mysql_query_response",
144                    "columns": [{"name": "version", "type": "VARCHAR"}],
145                    "rows": [["8.0.36"]]}]
146    elif q.startswith("SELECT"):
147        actions = [{"type": "mysql_query_response",
148                    "columns": [{"name": "id", "type": "INT"},
149                                {"name": "name", "type": "VARCHAR"}],
150                    "rows": [[1, "alice"], [2, "bob"]]}]
151    else:
152        actions = [{"type": "mysql_ok_response", "affected_rows": 0}]
153else:
154    actions = []
155print(json.dumps({"actions": actions}))"#;
156
157        StartupExamples::new(
158            // LLM mode: reason about the SQL to synthesise coherent rows.
159            json!({
160                "type": "open_server",
161                "port": 3306,
162                "base_stack": "mysql",
163                "instruction": "Act as a MySQL server for a 'products' database. Answer SELECT queries by generating plausible rows that satisfy the WHERE clause and requested columns; treat INSERT/UPDATE/DELETE as succeeding and report affected_rows. Keep results consistent across queries in the same session."
164            }),
165            // Script mode: fixed answers for known queries, no LLM call.
166            json!({
167                "type": "open_server",
168                "port": 3306,
169                "base_stack": "mysql",
170                "event_handlers": [{
171                    "event_pattern": "mysql_query",
172                    "handler": {
173                        "type": "script",
174                        "language": "python",
175                        "code": script
176                    }
177                }]
178            }),
179            // Static mode: Fixed responses
180            json!({
181                "type": "open_server",
182                "port": 3306,
183                "base_stack": "mysql",
184                "event_handlers": [{
185                    "event_pattern": "mysql_query",
186                    "handler": {
187                        "type": "static",
188                        "actions": [{
189                            "type": "mysql_query_response",
190                            "columns": [{"name": "result", "type": "VARCHAR"}],
191                            "rows": [["OK"]]
192                        }]
193                    }
194                }]
195            }),
196        )
197    }
198}
199
200// Implement Server trait (server-specific functionality)
201impl Server for MysqlProtocol {
202    fn spawn(
203        &self,
204        ctx: crate::protocol::SpawnContext,
205    ) -> std::pin::Pin<
206        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
207    > {
208        Box::pin(async move {
209            use crate::server::mysql::MysqlServer;
210            // `send_first` stays declared so a caller that passes `false` still validates - an
211            // undeclared key is refused outright - but `true` is refused rather than ignored.
212            // It was read here, threaded through spawn, and dropped at the far end as
213            // `_send_first`: a knob advertised to the model, plumbed through three hops, and
214            // silently doing nothing. MySQL: the server already sends its initial handshake packet unconditionally, because the protocol requires it - there is nothing for this flag to turn on.
215            let send_first = ctx
216                .startup_params
217                .as_ref()
218                .map(|p| p.get_optional_bool("send_first"))
219                .transpose()?
220                .flatten()
221                .unwrap_or(false);
222            if send_first {
223                return Err(anyhow::anyhow!(
224                    "send_first is not supported by the MySQL server: the server already sends its initial handshake packet unconditionally, because the protocol requires it - there is nothing for this flag to turn on"
225                ));
226            }
227
228            MysqlServer::spawn_with_llm_actions(
229                ctx.legacy_listen_addr(),
230                ctx.llm_client,
231                ctx.state,
232                ctx.status_tx,
233                send_first,
234                ctx.server_id,
235            )
236            .await
237        })
238    }
239    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
240        let action_type = action
241            .get("type")
242            .and_then(|v| v.as_str())
243            .context("Missing 'type' field in action")?;
244
245        match action_type {
246            "mysql_query_response" => self.execute_mysql_query_response(action),
247            "mysql_error_response" => self.execute_mysql_error_response(action),
248            "mysql_ok_response" => self.execute_mysql_ok_response(action),
249            "close_this_connection" => Ok(ActionResult::CloseConnection),
250            _ => Err(anyhow::anyhow!("Unknown MySQL action: {}", action_type)),
251        }
252    }
253}
254
255impl MysqlProtocol {
256    fn execute_mysql_query_response(&self, action: serde_json::Value) -> Result<ActionResult> {
257        // Extract columns and rows from the action
258        let columns = action
259            .get("columns")
260            .and_then(|v| v.as_array())
261            .context("Missing 'columns' array")?;
262
263        let rows = action
264            .get("rows")
265            .and_then(|v| v.as_array())
266            .context("Missing 'rows' array")?;
267
268        debug!(
269            "MySQL query response: {} columns, {} rows",
270            columns.len(),
271            rows.len()
272        );
273
274        let _ = self.status_tx.send(format!(
275            "[DEBUG] MySQL → Result set: {} columns, {} rows",
276            columns.len(),
277            rows.len()
278        ));
279
280        // Return a custom action result with the query response data
281        Ok(ActionResult::Custom {
282            name: "mysql_query_response".to_string(),
283            data: json!({
284                "columns": columns,
285                "rows": rows
286            }),
287        })
288    }
289
290    fn execute_mysql_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
291        // Truncating with `as u16` turned an out-of-range code into an unrelated one; keep the
292        // documented default instead.
293        let error_code = action
294            .get("error_code")
295            .and_then(|v| v.as_u64())
296            .and_then(|c| u16::try_from(c).ok())
297            .unwrap_or(1064);
298
299        let message = action
300            .get("message")
301            .and_then(|v| v.as_str())
302            .unwrap_or("Unknown error");
303
304        debug!("MySQL error response: {} - {}", error_code, message);
305
306        let _ = self
307            .status_tx
308            .send(format!("[DEBUG] MySQL ✗ Error {}: {}", error_code, message));
309
310        Ok(ActionResult::Custom {
311            name: "mysql_error".to_string(),
312            data: json!({
313                "error_code": error_code,
314                "message": message
315            }),
316        })
317    }
318
319    fn execute_mysql_ok_response(&self, action: serde_json::Value) -> Result<ActionResult> {
320        let affected_rows = action
321            .get("affected_rows")
322            .and_then(|v| v.as_u64())
323            .unwrap_or(0);
324
325        let last_insert_id = action
326            .get("last_insert_id")
327            .and_then(|v| v.as_u64())
328            .unwrap_or(0);
329
330        debug!(
331            "MySQL OK response: affected_rows={}, last_insert_id={}",
332            affected_rows, last_insert_id
333        );
334
335        let _ = self.status_tx.send(format!(
336            "[DEBUG] MySQL → OK: {} rows affected",
337            affected_rows
338        ));
339
340        Ok(ActionResult::Custom {
341            name: "mysql_ok".to_string(),
342            data: json!({
343                "affected_rows": affected_rows,
344                "last_insert_id": last_insert_id
345            }),
346        })
347    }
348}
349
350/// Action definition: Send MySQL query response
351pub fn mysql_query_response_action() -> ActionDefinition {
352    ActionDefinition {
353        name: "mysql_query_response".to_string(),
354        description: "The answer to any statement that returns rows - SELECT (including \
355                      SELECT 1, SELECT VERSION(), SELECT @@version_comment), SHOW, DESCRIBE. A \
356                      single value is one column and one row, and each row is an ARRAY of \
357                      values in column order, e.g. \"rows\": [[\"8.0.36\"]]. Never answer a \
358                      SELECT with mysql_ok_response: an OK packet carries no rows, so the client \
359                      prints nothing. One response per query."
360            .to_string(),
361        parameters: vec![
362            Parameter {
363                name: "columns".to_string(),
364                type_hint: "array".to_string(),
365                description: "Array of column definitions. Each column needs 'name' and 'type'. \
366                              Recognised types: INT, INTEGER, BIGINT, SMALLINT, TINYINT, FLOAT, DOUBLE, \
367                              DECIMAL, DATE, TIME, DATETIME, TIMESTAMP, BLOB, BINARY, TEXT, VARCHAR \
368                              (anything else is treated as VARCHAR). The type sets the column metadata \
369                              and how each value is encoded: send a number for the numeric types, \
370                              'YYYY-MM-DD' for DATE, 'HH:MM:SS' for TIME, 'YYYY-MM-DD HH:MM:SS' for \
371                              DATETIME/TIMESTAMP. A value that does not fit its column's type is sent \
372                              as NULL".to_string(),
373                required: true,
374            },
375            Parameter {
376                name: "rows".to_string(),
377                type_hint: "array".to_string(),
378                description: "Array of rows. Each row is an array of values matching the column \
379                              order. JSON null is a SQL NULL. Rows shorter than the column list \
380                              are padded with NULLs; extra values are dropped".to_string(),
381                required: true,
382            },
383        ],
384        example: json!({
385            "type": "mysql_query_response",
386            "columns": [{"name": "id", "type": "INT"}, {"name": "name", "type": "VARCHAR"}],
387            "rows": [[1, "Alice"], [2, "Bob"]]
388        }),
389        log_template: Some(
390            LogTemplate::new()
391                .with_info("-> MySQL {columns_len} cols, {rows_len} rows")
392                .with_debug("MySQL mysql_query_response: {columns_len} columns, {rows_len} rows"),
393        ),
394    }
395}
396
397/// Action definition: Send MySQL error response
398pub fn mysql_error_response_action() -> ActionDefinition {
399    ActionDefinition {
400        name: "mysql_error_response".to_string(),
401        description: "Send an error response to the client".to_string(),
402        parameters: vec![
403            Parameter {
404                name: "error_code".to_string(),
405                type_hint: "number".to_string(),
406                description:
407                    "MySQL error number. Sent verbatim when it is one of the recognised codes: \
408                     1044, 1045, 1046, 1049, 1050, 1051, 1052, 1054, 1062, 1064, 1065, 1136, \
409                     1146, 1149, 1216, 1217, 1364, 1451, 1452, 1690. Any other value is reported \
410                     to the client as 1105 (unknown error) with your message unchanged"
411                        .to_string(),
412                required: true,
413            },
414            Parameter {
415                name: "message".to_string(),
416                type_hint: "string".to_string(),
417                description: "Error message to display to the client".to_string(),
418                required: true,
419            },
420        ],
421        example: json!({
422            "type": "mysql_error_response",
423            "error_code": 1146,
424            "message": "Table 'database.table_name' doesn't exist"
425        }),
426        log_template: Some(
427            LogTemplate::new()
428                .with_info("-> MySQL ERR {error_code}: {message}")
429                .with_debug("MySQL mysql_error_response: code={error_code}, message={message}"),
430        ),
431    }
432}
433
434/// Action definition: Send MySQL OK response
435pub fn mysql_ok_response_action() -> ActionDefinition {
436    ActionDefinition {
437        name: "mysql_ok_response".to_string(),
438        description: "OK packet for a statement that returns no rows: INSERT, UPDATE, \
439                      DELETE, CREATE, SET, USE. Never for SELECT, SHOW or DESCRIBE - those are \
440                      answered with mysql_query_response."
441            .to_string(),
442        parameters: vec![
443            Parameter {
444                name: "affected_rows".to_string(),
445                type_hint: "number".to_string(),
446                description: "Number of rows affected by the query".to_string(),
447                required: false,
448            },
449            Parameter {
450                name: "last_insert_id".to_string(),
451                type_hint: "number".to_string(),
452                description: "Last insert ID for INSERT queries with auto_increment".to_string(),
453                required: false,
454            },
455        ],
456        example: json!({
457            "type": "mysql_ok_response",
458            "affected_rows": 1,
459            "last_insert_id": 42
460        }),
461        log_template: Some(
462            LogTemplate::new()
463                .with_info("-> MySQL OK, {affected_rows} rows affected")
464                .with_debug("MySQL mysql_ok_response: affected_rows={affected_rows}, last_insert_id={last_insert_id}"),
465        ),
466    }
467}
468
469/// Action definition: Close current MySQL connection
470pub fn close_this_connection_action() -> ActionDefinition {
471    ActionDefinition {
472        name: "close_this_connection".to_string(),
473        description: "Close the current MySQL connection".to_string(),
474        parameters: vec![],
475        example: json!({"type": "close_this_connection"}),
476        log_template: Some(
477            LogTemplate::new()
478                .with_info("MySQL connection closed")
479                .with_debug("MySQL close_this_connection"),
480        ),
481    }
482}
483
484// ============================================================================
485// MySQL Action Constants
486//
487// The same four definitions the `Protocol` impl advertises, as statics the event type can
488// clone. They delegate to the functions above rather than repeating them: the two copies were
489// byte-identical duplicates of ~120 lines, and a description edited in one and not the other
490// would have shown the model one text and the executor another.
491// ============================================================================
492
493/// MySQL query response action constant
494pub static MYSQL_QUERY_RESPONSE_ACTION: LazyLock<ActionDefinition> =
495    LazyLock::new(mysql_query_response_action);
496
497/// MySQL error response action constant
498pub static MYSQL_ERROR_RESPONSE_ACTION: LazyLock<ActionDefinition> =
499    LazyLock::new(mysql_error_response_action);
500
501/// MySQL OK response action constant
502pub static MYSQL_OK_RESPONSE_ACTION: LazyLock<ActionDefinition> =
503    LazyLock::new(mysql_ok_response_action);
504
505/// MySQL close connection action constant
506pub static MYSQL_CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
507    LazyLock::new(close_this_connection_action);
508
509// ============================================================================
510// MySQL Event Type Constants
511// ============================================================================
512
513/// MySQL query event - triggered when client sends a query
514pub static MYSQL_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
515    EventType::new(
516        "mysql_query",
517        "MySQL query received from client",
518        json!({"type": "placeholder", "event_id": "mysql_query"}),
519    )
520    .with_parameters(vec![
521        Parameter {
522            name: "query".to_string(),
523            type_hint: "string".to_string(),
524            description: "The SQL query string sent by the client".to_string(),
525            required: true,
526        },
527        Parameter {
528            name: "answer_with".to_string(),
529            type_hint: "string".to_string(),
530            description: "Which response shape the statement needs, from its first keyword. \
531                          Absent when the server cannot tell."
532                .to_string(),
533            required: false,
534        },
535    ])
536    .with_actions(vec![
537        MYSQL_QUERY_RESPONSE_ACTION.clone(),
538        MYSQL_ERROR_RESPONSE_ACTION.clone(),
539        MYSQL_OK_RESPONSE_ACTION.clone(),
540        MYSQL_CLOSE_CONNECTION_ACTION.clone(),
541    ])
542    .with_log_template(
543        LogTemplate::new()
544            .with_info("MySQL: {preview(query,80)}")
545            .with_debug("MySQL query: {query}")
546            .with_trace("MySQL: {json_pretty(.)}"),
547    )
548});
549
550/// The `answer_with` event field for `sql`, or `None` when its first keyword does not say.
551///
552/// See `crate::utils::sql`. Told to "report the server version as 8.0.36-netget-eval", the model
553/// answered the client's own `SELECT @@version_comment` and `SELECT VERSION()` with OK packets or
554/// with rows written as objects, and the client printed nothing.
555pub fn answer_with_for_query(sql: &str) -> Option<&'static str> {
556    use crate::utils::sql::{statement_shape, StatementShape};
557    match statement_shape(sql) {
558        StatementShape::Rows => Some(
559            "mysql_query_response: this statement returns rows - columns, and rows as arrays of \
560             values (one column and one row for a single value) - never an OK packet",
561        ),
562        StatementShape::NoRows => Some(
563            "mysql_ok_response: this statement returns no rows - affected_rows and, for an \
564             INSERT, last_insert_id",
565        ),
566        StatementShape::Unknown => None,
567    }
568}
569
570/// Get MySQL event types
571pub fn get_mysql_event_types() -> Vec<EventType> {
572    vec![MYSQL_QUERY_EVENT.clone()]
573}