Skip to main content

netget/server/mysql/
mod.rs

1//! MySQL server implementation using opensrv-mysql
2//!
3//! **Inbound packets are bounded before they are buffered.** `opensrv-mysql` has no
4//! maximum-packet check of any kind, and it is the crate that answers
5//! `SELECT @@max_allowed_packet` with 67108864 — so until now this server published a ceiling
6//! and enforced nothing, on an unauthenticated connection, before any model call.
7//! `packet_limit` puts that number where the bytes are: see [`packet_limit::MAX_PACKET_BYTES`].
8pub mod actions;
9pub mod caching_sha2;
10pub mod packet_limit;
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::actions::protocol_trait::ActionResult;
14use crate::llm::ollama_client::OllamaClient;
15use crate::logging::emit::Log;
16use crate::protocol::Event;
17use crate::server::connection::ConnectionId;
18use crate::state::app_state::AppState;
19use actions::{MysqlProtocol, MYSQL_QUERY_EVENT};
20use anyhow::Result;
21use async_trait::async_trait;
22use opensrv_mysql::{
23    AsyncMysqlIntermediary, AsyncMysqlShim, Column, ColumnFlags, ColumnType, ErrorKind, InitWriter,
24    OkResponse, ParamParser, QueryResultWriter, RowWriter, StatementMetaWriter, StatusFlags,
25};
26use std::io;
27use std::net::SocketAddr;
28use std::sync::Arc;
29use tokio::net::TcpListener;
30use tokio::sync::{mpsc, Mutex};
31use tracing::{debug, error, trace, warn};
32
33/// How long to wait for the client's handshake response after the greeting goes out.
34///
35/// MySQL is server-speaks-first: `opensrv-mysql` writes the initial handshake packet, and a
36/// real client answers it at once — it has the credentials in hand already and asks nobody.
37/// This is therefore a bound on "connected, took the greeting, said nothing", which is exactly
38/// what an unauthenticated flood looks like.
39const HANDSHAKE_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
40
41/// How long to wait for a *further* command once the session is up.
42///
43/// Real MySQL's `wait_timeout` defaults to **eight hours**, which is a bound in name only, so
44/// copying it is not an option here the way copying Kafka's `connections.max.idle.ms` was. Ten
45/// minutes instead, and the reason it is safe is what this server *is*: it holds no session
46/// state a reconnect would lose — no tables, no temporary tables, no open transaction, nothing
47/// the model remembers between statements (see the no-storage rule in the project CLAUDE.md).
48/// A pooled connection reaped here costs `mysql_async` or a JDBC pool one transparent reconnect,
49/// and ten minutes is far above any pool's keepalive interval.
50const IDLE_BETWEEN_COMMANDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
51
52/// Concurrent connections this server admits.
53const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
54
55/// How many further octets are read and **discarded** after a packet is refused, so the peer
56/// can finish writing and then read the ERR packet.
57///
58/// A peer refused at [`packet_limit::MAX_PACKET_BYTES`] is by definition still writing: it is
59/// part-way through a packet it declared as larger than we will take. Closing the socket while
60/// data is still in the receive queue sends `RST`, and `RST` discards the response bytes
61/// already written along with it — so the peer's `write` fails with `ECONNRESET` and the
62/// carefully-numbered ERR packet, the one thing that tells it *why*, is never delivered.
63///
64/// This is nginx's `lingering_close`, and it is bounded for the same reason: draining is
65/// politeness, not an obligation. Nothing is buffered — the octets are counted and dropped.
66const LINGER_DRAIN_BYTES: usize = 8 * 1024 * 1024;
67
68/// Wall-clock bound on that drain, so a peer trickling one octet at a time cannot hold the
69/// connection open by staying under [`LINGER_DRAIN_BYTES`].
70const LINGER_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
71
72/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
73///
74/// An ERR packet carrying error **1040, `ER_CON_COUNT_ERROR`, SQLSTATE `08004`** — which is
75/// precisely what a real MySQL server sends, in precisely this position, when `max_connections`
76/// is reached: in place of the initial handshake, as packet sequence 0. Every client in the
77/// ecosystem already recognises it, so `mysql_async` surfaces "Too many connections" and the
78/// `mysql` CLI prints `ERROR 1040 (08004)` rather than reporting a broken pipe.
79///
80/// Laid out by hand because it is a wire packet: a 3-byte little-endian payload length (29) and
81/// a sequence byte (0), then `0xFF` marking an ERR packet, the error number 1040 little-endian,
82/// the `#` SQL-state marker, the five-character state, and the message.
83const CONNECTION_CAP_REFUSAL: &[u8] = b"\x1d\x00\x00\x00\xff\x10\x04#08004Too many connections";
84
85/// Cap on prepared statements retained per connection.
86///
87/// The map is keyed by statement id and only pruned by an explicit COM_STMT_CLOSE, so a client
88/// that PREPAREs in a loop and never closes would grow it without bound.
89const MAX_PREPARED_STATEMENTS: usize = 4096;
90
91/// Most `?` placeholders one prepared statement may declare.
92///
93/// Real MySQL's own limit is 65535. This is far lower because the descriptors are held in a
94/// process-wide table (`PLACEHOLDER_COLUMNS`) and no statement a model or an ORM produces
95/// comes anywhere near it; anything past it gets `ER_PS_MANY_PARAM` rather than a silently
96/// wrong parameter count.
97const MAX_PLACEHOLDERS: usize = 1024;
98
99/// Parameter descriptors handed to `StatementMetaWriter::reply`.
100///
101/// They have to outlive the writer's borrow, so they cannot be built per statement; the
102/// contents are identical for every statement anyway — real MySQL names each parameter `?`
103/// and types it as a string, and nothing reads either field because `on_execute` does not
104/// decode parameter values.
105static PLACEHOLDER_COLUMNS: std::sync::LazyLock<Vec<Column>> = std::sync::LazyLock::new(|| {
106    (0..MAX_PLACEHOLDERS)
107        .map(|_| Column {
108            table: String::new(),
109            column: "?".to_string(),
110            coltype: ColumnType::MYSQL_TYPE_VAR_STRING,
111            colflags: ColumnFlags::empty(),
112        })
113        .collect()
114});
115
116/// MySQL server implementation
117pub struct MysqlServer {
118    llm_client: OllamaClient,
119    app_state: Arc<AppState>,
120    _status_tx: mpsc::UnboundedSender<String>,
121    server_id: Option<crate::state::ServerId>,
122}
123
124impl MysqlServer {
125    /// Create a new MySQL server
126    pub fn new(
127        llm_client: OllamaClient,
128        app_state: Arc<AppState>,
129        status_tx: mpsc::UnboundedSender<String>,
130        server_id: Option<crate::state::ServerId>,
131    ) -> Self {
132        Self {
133            llm_client,
134            app_state,
135            _status_tx: status_tx,
136            server_id,
137        }
138    }
139
140    /// Spawn MySQL server with LLM integration
141    pub async fn spawn_with_llm_actions(
142        listen_addr: SocketAddr,
143        llm_client: OllamaClient,
144        app_state: Arc<AppState>,
145        status_tx: mpsc::UnboundedSender<String>,
146        _send_first: bool,
147        server_id: crate::state::ServerId,
148    ) -> Result<SocketAddr> {
149        let listener = TcpListener::bind(listen_addr).await?;
150        let actual_addr = listener.local_addr()?;
151
152        Log::new(Some(&status_tx)).info(format!("MySQL server listening on {}", actual_addr));
153
154        let server = Arc::new(MysqlServer::new(
155            llm_client,
156            app_state.clone(),
157            status_tx.clone(),
158            Some(server_id),
159        ));
160
161        let status_tx_clone = status_tx.clone();
162        let task_registrar = app_state.clone();
163
164        // Spawn the accept loop
165        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
166        let accept_handle = tokio::spawn(async move {
167            loop {
168                match crate::server::accept_bounded::accept_bounded(
169                    &listener,
170                    &limiter,
171                    CONNECTION_CAP_REFUSAL,
172                    "MySQL",
173                    Some(&status_tx),
174                )
175                .await
176                {
177                    Ok((stream, addr, permit)) => {
178                        Log::new(Some(&status_tx)).info(format!("MySQL connection from {}", addr));
179
180                        let connection_id =
181                            ConnectionId::new(app_state.get_next_unified_id().await);
182                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
183
184                        let handler = MysqlHandler::new(
185                            connection_id,
186                            server.llm_client.clone(),
187                            server.app_state.clone(),
188                            status_tx.clone(),
189                            server.server_id,
190                            addr,
191                        );
192
193                        // Track the connection
194                        if let Some(server_id) = server.server_id {
195                            use crate::state::server::{
196                                ConnectionState as ServerConnectionState, ConnectionStatus,
197                                ProtocolConnectionInfo,
198                            };
199                            let now = crate::utils::clock::Instant::now();
200                            let conn_state = ServerConnectionState {
201                                id: connection_id,
202                                remote_addr: addr,
203                                local_addr: local_addr_conn,
204                                bytes_sent: 0,
205                                bytes_received: 0,
206                                packets_sent: 0,
207                                packets_received: 0,
208                                last_activity: now,
209                                status: ConnectionStatus::Active,
210                                status_changed_at: now,
211                                protocol_info: ProtocolConnectionInfo::empty(),
212                            };
213                            server
214                                .app_state
215                                .add_connection_to_server(server_id, conn_state)
216                                .await;
217                        }
218
219                        let conn_state_owner = server.app_state.clone();
220                        let conn_server_id = server.server_id;
221                        let status_tx_conn = status_tx.clone();
222                        let conn_handle = tokio::spawn(async move {
223                            // Held for the life of the session, so the cap counts live
224                            // connections rather than accepts.
225                            let _permit = permit;
226                            // MySQL requires split read/write streams
227                            let (reader, mut writer) = tokio::io::split(stream);
228                            // `AsyncMysqlIntermediary::run_on` owns the protocol loop, so there
229                            // is no `read()` of ours to wrap in a deadline — but it takes a
230                            // *generic* reader, which is the seam. `IdleTimeoutReader` arms its
231                            // clock only while a read is actually outstanding, so the LLM
232                            // round-trip that answers a query, and a `manual` rule parking that
233                            // query for a human (`src/state/intercepts.rs`, 300s by default),
234                            // run with no deadline over them at all: the reader is not being
235                            // polled while `MysqlHandler` is working.
236                            let reader =
237                                crate::server::accept_bounded::IdleTimeoutReader::with_first(
238                                    reader,
239                                    HANDSHAKE_RESPONSE_TIMEOUT,
240                                    IDLE_BETWEEN_COMMANDS_TIMEOUT,
241                                );
242                            // The packet bound sits *under* opensrv-mysql, for the same reason
243                            // the idle bound does: the crate owns the protocol loop, but it
244                            // takes a generic reader, and a reader is where a length field can
245                            // be refused before the payload behind it is read. See
246                            // `packet_limit`.
247                            let trip = Arc::new(packet_limit::PacketLimitTrip::default());
248                            let mut reader =
249                                packet_limit::PacketLimitReader::new(reader, trip.clone());
250                            // `run_on` takes the writer **by value**, and its `W` is only
251                            // `AsyncWrite + Send + Unpin` — which `&mut WriteHalf` satisfies.
252                            // That is the seam: lending the write half rather than giving it
253                            // away leaves this task able to answer once the crate's loop has
254                            // given up, which is the only moment at which a refusal decided
255                            // beneath the crate can be expressed in the crate's protocol.
256                            //
257                            // The same seam carries the one packet a `caching_sha2_password`
258                            // client waits for between its scramble and the OK packet: the
259                            // crate writes that OK itself and its `authenticate` hook cannot
260                            // write, so the packet is injected beneath it. See
261                            // `caching_sha2`. The borrow ends with the block, leaving this
262                            // task the write half for the refusal path below.
263                            let outcome = {
264                                let mut writer = caching_sha2::FastAuthWriter::new(
265                                    &mut writer,
266                                    handler.fast_auth_gate(),
267                                );
268                                AsyncMysqlIntermediary::run_on(handler, &mut reader, &mut writer)
269                                    .await
270                            };
271                            if trip.tripped() {
272                                Self::refuse_oversized_packet(
273                                    connection_id,
274                                    addr,
275                                    &trip,
276                                    reader.into_inner(),
277                                    &mut writer,
278                                    &status_tx_conn,
279                                )
280                                .await;
281                            } else if let Err(e) = outcome {
282                                error!("MySQL connection error: {:?}", e);
283                            }
284                            // Mark the connection closed so it does not stay Active forever
285                            // in the server's connection map.
286                            if let Some(server_id) = conn_server_id {
287                                conn_state_owner
288                                    .close_connection_on_server(server_id, connection_id)
289                                    .await;
290                            }
291                        });
292
293                        // Register the per-connection task too, not just the accept loop:
294                        // aborting the accept loop releases the port but leaves every
295                        // in-flight session running, so `stop_server` did not actually stop
296                        // the server. `register_server_task` prunes finished handles on each
297                        // call, so this cannot grow without bound.
298                        if let Some(server_id) = conn_server_id {
299                            app_state.register_server_task(server_id, conn_handle).await;
300                        }
301                    }
302                    Err(e) => {
303                        Log::new(Some(&status_tx)).error(format!("MySQL accept error: {}", e));
304                    }
305                }
306            }
307        });
308
309        // Register the accept loop so stop_server can abort it and release the port.
310        task_registrar
311            .register_server_task(server_id, accept_handle)
312            .await;
313
314        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
315        Ok(actual_addr)
316    }
317
318    /// Tell a peer, in MySQL's own vocabulary, that the packet it declared is too large.
319    ///
320    /// Order matters and is nginx's, not the obvious one. The ERR packet is written **first**,
321    /// so it is on the wire while the peer is still writing and can be read the moment the
322    /// peer looks; then the receive queue is drained, boundedly, so that the close which
323    /// follows is a `FIN` and not an `RST`. A close with unread data queued discards
324    /// everything already written to the socket, which would make the error number, the
325    /// SQLSTATE and the sequence arithmetic above all equally invisible.
326    async fn refuse_oversized_packet<R, W>(
327        connection_id: ConnectionId,
328        remote_addr: SocketAddr,
329        trip: &packet_limit::PacketLimitTrip,
330        mut reader: R,
331        writer: &mut W,
332        status_tx: &mpsc::UnboundedSender<String>,
333    ) where
334        R: tokio::io::AsyncRead + Unpin,
335        W: tokio::io::AsyncWrite + Unpin,
336    {
337        use tokio::io::{AsyncReadExt, AsyncWriteExt};
338
339        let declared = trip.declared_bytes();
340        let sequence = trip.reply_sequence();
341
342        Log::new(Some(status_tx)).warn(format!(
343            "MySQL connection {connection_id} decision=fail_closed_packet_too_large: {remote_addr} \
344             declared a {declared}-byte packet, limit is {} bytes; answering 1153 \
345             ER_NET_PACKET_TOO_LARGE",
346            packet_limit::MAX_PACKET_BYTES
347        ));
348
349        let err = packet_limit::packet_too_large_err(sequence);
350        if let Err(e) = writer.write_all(&err).await {
351            debug!("MySQL {connection_id}: could not write the 1153 refusal: {e}");
352            return;
353        }
354        if let Err(e) = writer.flush().await {
355            debug!("MySQL {connection_id}: could not flush the 1153 refusal: {e}");
356        }
357
358        let mut drained = 0usize;
359        let mut scratch = vec![0u8; 64 * 1024];
360        let _ = tokio::time::timeout(LINGER_DRAIN_TIMEOUT, async {
361            while drained < LINGER_DRAIN_BYTES {
362                match reader.read(&mut scratch).await {
363                    Ok(0) | Err(_) => break,
364                    Ok(n) => drained = drained.saturating_add(n),
365                }
366            }
367        })
368        .await;
369
370        trace!("MySQL {connection_id}: drained {drained} bytes after refusing the packet");
371        if let Err(e) = writer.shutdown().await {
372            debug!("MySQL {connection_id}: shutdown after refusal returned: {e}");
373        }
374    }
375}
376
377/// MySQL connection handler
378pub struct MysqlHandler {
379    connection_id: ConnectionId,
380    llm_client: OllamaClient,
381    app_state: Arc<AppState>,
382    status_tx: mpsc::UnboundedSender<String>,
383    #[allow(dead_code)]
384    server_id: Option<crate::state::ServerId>,
385    #[allow(dead_code)]
386    remote_addr: SocketAddr,
387    /// MySQL protocol handler for action execution
388    protocol: Arc<MysqlProtocol>,
389    /// Prepared statements
390    prepared_statements: Arc<Mutex<std::collections::HashMap<u32, String>>>,
391    /// Next statement ID
392    next_stmt_id: Arc<Mutex<u32>>,
393    /// How `authenticate` asks the writer beneath `opensrv-mysql` for the one packet a
394    /// `caching_sha2_password` client waits for. See [`caching_sha2`].
395    fast_auth: Arc<caching_sha2::FastAuthGate>,
396}
397
398impl MysqlHandler {
399    pub fn new(
400        connection_id: ConnectionId,
401        llm_client: OllamaClient,
402        app_state: Arc<AppState>,
403        status_tx: mpsc::UnboundedSender<String>,
404        server_id: Option<crate::state::ServerId>,
405        remote_addr: SocketAddr,
406    ) -> Self {
407        let protocol = Arc::new(MysqlProtocol::new(
408            connection_id,
409            app_state.clone(),
410            status_tx.clone(),
411        ));
412
413        Self {
414            connection_id,
415            llm_client,
416            app_state,
417            status_tx,
418            server_id,
419            remote_addr,
420            protocol,
421            prepared_statements: Arc::new(Mutex::new(std::collections::HashMap::new())),
422            next_stmt_id: Arc::new(Mutex::new(1)),
423            fast_auth: Arc::new(caching_sha2::FastAuthGate::default()),
424        }
425    }
426
427    /// The gate this handler will arm from `authenticate`, for the writer that answers it.
428    pub fn fast_auth_gate(&self) -> Arc<caching_sha2::FastAuthGate> {
429        self.fast_auth.clone()
430    }
431}
432
433#[async_trait]
434impl<W: tokio::io::AsyncWrite + Send + Unpin> AsyncMysqlShim<W> for MysqlHandler {
435    type Error = io::Error;
436
437    /// The plugin named in the greeting.
438    ///
439    /// `opensrv-mysql`'s default is `mysql_native_password`, whose *client* plugin MySQL 9.0
440    /// deleted — so the shipping CLI cannot load it and never reaches the query phase. See
441    /// [`caching_sha2`], and note what it does not mean: nothing here checks a password.
442    fn default_auth_plugin(&self) -> &str {
443        caching_sha2::CACHING_SHA2_PASSWORD
444    }
445
446    /// Never ask a client to switch plugins.
447    ///
448    /// An empty expectation is `opensrv-mysql`'s "no auth-switch": whatever the client chose
449    /// is what this connection uses. That is the honest answer for a server that verifies
450    /// nothing, and it is what keeps an older `mysql_native_password` client working — it
451    /// answers the greeting with its own plugin and is accepted as it always was, rather than
452    /// being sent an `AuthSwitchRequest` for a plugin it may not have.
453    async fn auth_plugin_for_username(&self, _user: &[u8]) -> &str {
454        ""
455    }
456
457    /// Admit the connection. **Nothing is verified** — there is no password here to verify
458    /// against, and the model is not consulted.
459    ///
460    /// The only decision taken is which shape the *end* of the connection phase has: a client
461    /// that sent a 32-byte `caching_sha2_password` scramble is blocked reading for
462    /// `AuthMoreData`, and gets it; every other client is answered with the OK packet alone.
463    async fn authenticate(
464        &self,
465        _auth_plugin: &str,
466        _username: &[u8],
467        _salt: &[u8],
468        auth_data: &[u8],
469    ) -> bool {
470        if caching_sha2::awaits_fast_auth_success(auth_data) {
471            self.fast_auth.arm();
472        }
473        true
474    }
475
476    async fn on_prepare<'a>(
477        &'a mut self,
478        query: &'a str,
479        info: StatementMetaWriter<'a, W>,
480    ) -> io::Result<()> {
481        Log::new(Some(&self.status_tx)).debug(format!("MySQL PREPARE: {}", query));
482        self.record_stats(Some(query.len() as u64), None, Some(1), None)
483            .await;
484
485        // Store the prepared statement
486        let mut next_id = self.next_stmt_id.lock().await;
487        let stmt_id = *next_id;
488        *next_id = next_id.wrapping_add(1);
489        drop(next_id);
490
491        let mut stmts = self.prepared_statements.lock().await;
492        if stmts.len() >= MAX_PREPARED_STATEMENTS {
493            drop(stmts);
494            warn!(
495                "MySQL connection {} exceeded {} prepared statements",
496                self.connection_id, MAX_PREPARED_STATEMENTS
497            );
498            return info
499                .error(
500                    ErrorKind::ER_MAX_PREPARED_STMT_COUNT_REACHED,
501                    b"Can't create more than max_prepared_stmt_count statements",
502                )
503                .await;
504        }
505        stmts.insert(stmt_id, query.to_string());
506        drop(stmts);
507
508        // Reply with the statement ID and the number of `?` placeholders the statement has.
509        //
510        // The count is not cosmetic: the client stores it and uses it to frame
511        // COM_STMT_EXECUTE. Replying `&[]` unconditionally — as this did — told every client
512        // the statement took no parameters, so `conn.exec("… WHERE id = ?", (42,))` was
513        // rejected client-side and never reached the wire at all. The parameter *values* are
514        // still not substituted (the model sees the `?`), and `on_execute` deliberately does
515        // not iterate `ParamParser`: opensrv-mysql's `params.rs` panics on several malformed
516        // COM_STMT_EXECUTE shapes, including an explicit `panic!("bad column type")` on a
517        // client-chosen byte, so reading them would trade a limitation for a remotely
518        // reachable panic.
519        let placeholders = count_placeholders(query);
520        if placeholders > MAX_PLACEHOLDERS {
521            warn!(
522                "MySQL connection {}: statement declares {} placeholders, more than the {} \
523                 supported",
524                self.connection_id, placeholders, MAX_PLACEHOLDERS
525            );
526            let mut stmts = self.prepared_statements.lock().await;
527            stmts.remove(&stmt_id);
528            drop(stmts);
529            return info
530                .error(
531                    ErrorKind::ER_PS_MANY_PARAM,
532                    b"Prepared statement contains too many placeholders",
533                )
534                .await;
535        }
536
537        self.record_stats(None, Some(0), None, Some(1)).await;
538        info.reply(stmt_id, &PLACEHOLDER_COLUMNS[..placeholders], &[])
539            .await
540    }
541
542    async fn on_execute<'a>(
543        &'a mut self,
544        stmt_id: u32,
545        _params: ParamParser<'a>,
546        results: QueryResultWriter<'a, W>,
547    ) -> io::Result<()> {
548        Log::new(Some(&self.status_tx)).debug(format!("MySQL EXECUTE statement {}", stmt_id));
549
550        // Get the prepared statement
551        let stmts = self.prepared_statements.lock().await;
552        let query = stmts.get(&stmt_id).cloned();
553        drop(stmts);
554
555        if let Some(query) = query {
556            // Treat as a regular query
557            self.handle_query(&query, results).await
558        } else {
559            // The handle is unknown or already closed. Answering OK told the driver its
560            // EXECUTE succeeded and affected zero rows, so a client reusing a stale handle —
561            // after a reconnect, or a double-close — saw a successful write that never ran.
562            // MySQL has a code for exactly this and drivers act on it.
563            warn!(
564                "MySQL EXECUTE for unknown or expired statement id {} (decision=unknown_handle)",
565                stmt_id
566            );
567            results
568                .error(
569                    ErrorKind::ER_UNKNOWN_STMT_HANDLER,
570                    format!(
571                        "Unknown prepared statement handler ({}) given to EXECUTE",
572                        stmt_id
573                    )
574                    .as_bytes(),
575                )
576                .await
577        }
578    }
579
580    async fn on_close(&mut self, stmt_id: u32) {
581        Log::new(Some(&self.status_tx)).debug(format!("MySQL CLOSE statement {}", stmt_id));
582
583        let mut stmts = self.prepared_statements.lock().await;
584        stmts.remove(&stmt_id);
585    }
586
587    async fn on_query<'a>(
588        &'a mut self,
589        query: &'a str,
590        results: QueryResultWriter<'a, W>,
591    ) -> io::Result<()> {
592        // FileOnly: the mysql_query event's log_template already reports the query to the
593        // TUI at INFO when call_llm dispatches it (see actions.rs).
594        Log::new(Some(&self.status_tx)).debug(format!("MySQL QUERY: {}", query));
595
596        self.handle_query(query, results).await
597    }
598
599    async fn on_init<'a>(
600        &'a mut self,
601        _database: &'a str,
602        writer: InitWriter<'a, W>,
603    ) -> io::Result<()> {
604        Log::new(Some(&self.status_tx)).debug(format!("MySQL INIT DB: {}", _database));
605        self.record_stats(Some(_database.len() as u64), None, Some(1), Some(1))
606            .await;
607
608        writer.ok().await
609    }
610}
611
612impl MysqlHandler {
613    /// Refresh the dashboard's per-connection counters and `last_activity`.
614    ///
615    /// opensrv-mysql owns the socket inside `run_on` and never exposes the raw byte streams, so
616    /// these are the **application-visible** payload sizes seen at the shim boundary (the SQL text
617    /// received, the response cells/message produced), not the exact wire bytes — opensrv adds a
618    /// 4-byte packet header and text-protocol framing on top. Good enough for the rail's `↓/↑`
619    /// counters and, more importantly, for keeping `last_activity` current.
620    async fn record_stats(
621        &self,
622        bytes_in: Option<u64>,
623        bytes_out: Option<u64>,
624        packets_in: Option<u64>,
625        packets_out: Option<u64>,
626    ) {
627        if let Some(server_id) = self.server_id {
628            self.app_state
629                .update_connection_stats(
630                    server_id,
631                    self.connection_id,
632                    bytes_in,
633                    bytes_out,
634                    packets_in,
635                    packets_out,
636                )
637                .await;
638        }
639    }
640
641    async fn handle_query<'a, W: tokio::io::AsyncWrite + Send + Unpin>(
642        &'a mut self,
643        query: &str,
644        results: QueryResultWriter<'a, W>,
645    ) -> io::Result<()> {
646        trace!("Calling LLM for MySQL query: {}", query);
647
648        // The SQL text is the visible inbound payload for this command (COM_QUERY, or the replayed
649        // statement text for COM_STMT_EXECUTE).
650        self.record_stats(Some(query.len() as u64), None, Some(1), None)
651            .await;
652
653        // Create query event
654        let mut event_data = serde_json::json!({ "query": query });
655        if let Some(hint) = actions::answer_with_for_query(query) {
656            event_data["answer_with"] = serde_json::json!(hint);
657        }
658        let event = Event::new(&MYSQL_QUERY_EVENT, event_data);
659
660        let server_id = self
661            .server_id
662            .unwrap_or_else(|| crate::state::ServerId::new(0));
663
664        let llm_result = call_llm(
665            &self.llm_client,
666            &self.app_state,
667            server_id,
668            Some(self.connection_id),
669            &event,
670            self.protocol.as_ref(),
671        )
672        .await;
673
674        match llm_result {
675            Ok(execution_result) => {
676                // `close_this_connection` is a declared sync action, so it has to actually end
677                // the session. opensrv drives the loop for us, so the only way to stop it is to
678                // answer the current query and then return an error from the shim.
679                let close_requested = execution_result
680                    .protocol_results
681                    .iter()
682                    .any(|r| matches!(r, ActionResult::CloseConnection));
683
684                // One query, one answer: the first response action is sent and any further one
685                // is dropped, and said so. The eval saw one SELECT answered with five alternating
686                // OK packets and notes; a second result for the same query has nowhere to go on
687                // the wire, and dropping it silently would make the log read as though the
688                // model's last answer were the one the client got.
689                let responses = execution_result
690                    .protocol_results
691                    .iter()
692                    .filter(|r| {
693                        matches!(r, ActionResult::Custom { name, .. }
694                            if matches!(name.as_str(), "mysql_query_response" | "mysql_error" | "mysql_ok"))
695                    })
696                    .count();
697                if responses > 1 {
698                    warn!(
699                        "MySQL connection {} decision=duplicate_response_dropped: {} response \
700                         actions for one query; sending the first",
701                        self.connection_id, responses
702                    );
703                }
704
705                // Process action results to find MySQL responses
706                for result in execution_result.protocol_results {
707                    match result {
708                        ActionResult::Custom { name, data } => {
709                            match name.as_str() {
710                                "mysql_query_response" => {
711                                    // Extract columns and rows from JSON data
712                                    let columns = data
713                                        .get("columns")
714                                        .and_then(|v| v.as_array())
715                                        .cloned()
716                                        .unwrap_or_default();
717                                    let rows = data
718                                        .get("rows")
719                                        .and_then(|v| v.as_array())
720                                        .cloned()
721                                        .unwrap_or_default();
722
723                                    // Estimate the outbound payload from the cell text before the
724                                    // writers consume `columns`/`rows`.
725                                    let sent_bytes: u64 = columns
726                                        .iter()
727                                        .filter_map(|c| c.get("name"))
728                                        .filter_map(|v| v.as_str())
729                                        .map(|s| s.len() as u64)
730                                        .sum::<u64>()
731                                        + rows
732                                            .iter()
733                                            .filter_map(|r| r.as_array())
734                                            .flatten()
735                                            .map(|v| json_to_mysql_string(v).len() as u64)
736                                            .sum::<u64>();
737                                    self.record_stats(None, Some(sent_bytes), None, Some(1))
738                                        .await;
739
740                                    // Send result set
741                                    return finish_query(
742                                        send_result_set(results, columns, rows).await,
743                                        close_requested,
744                                    );
745                                }
746                                "mysql_error" => {
747                                    // Extract error info from JSON data
748                                    let error_code = data
749                                        .get("error_code")
750                                        .and_then(|v| v.as_u64())
751                                        .and_then(|c| u16::try_from(c).ok())
752                                        .unwrap_or(1064);
753                                    let message = data
754                                        .get("message")
755                                        .and_then(|v| v.as_str())
756                                        .unwrap_or("Unknown error");
757
758                                    // Send a real MySQL ERR packet. `QueryResultWriter::error`
759                                    // has existed since opensrv-mysql 0.4; the previous code
760                                    // (and the protocol docs) claimed the library could only
761                                    // send OK, which silently swallowed every LLM error.
762                                    //
763                                    // Non-fatal: this is the model's own deliberate
764                                    // mysql_error_response, answered on the wire and the
765                                    // connection continues.
766                                    Log::new(Some(&self.status_tx))
767                                        .warn(format!("MySQL error {}: {}", error_code, message));
768                                    self.record_stats(
769                                        None,
770                                        Some(message.len() as u64),
771                                        None,
772                                        Some(1),
773                                    )
774                                    .await;
775                                    return finish_query(
776                                        results
777                                            .error(mysql_error_kind(error_code), message.as_bytes())
778                                            .await,
779                                        close_requested,
780                                    );
781                                }
782                                "mysql_ok" => {
783                                    // Extract OK response info from JSON data
784                                    let affected_rows = data
785                                        .get("affected_rows")
786                                        .and_then(|v| v.as_u64())
787                                        .unwrap_or(0);
788                                    let last_insert_id = data
789                                        .get("last_insert_id")
790                                        .and_then(|v| v.as_u64())
791                                        .unwrap_or(0);
792
793                                    // Send OK response (a small fixed-size packet).
794                                    self.record_stats(None, Some(0), None, Some(1)).await;
795                                    return finish_query(
796                                        results
797                                            .completed(OkResponse {
798                                                header: 0,
799                                                affected_rows,
800                                                last_insert_id,
801                                                status_flags: StatusFlags::empty(),
802                                                warnings: 0,
803                                                info: String::new(),
804                                                session_state_info: String::new(),
805                                            })
806                                            .await,
807                                        close_requested,
808                                    );
809                                }
810                                _ => {
811                                    // Unknown custom response, ignore
812                                }
813                            }
814                        }
815                        _ => {
816                            // Other action results are informational, continue processing
817                        }
818                    }
819                }
820
821                // No response action matched: the handler ran but produced nothing this
822                // protocol can encode — a model that refused, a static handler with an empty
823                // action list, or an answer whose actions were all unrecognised.
824                //
825                // This used to reply with an empty OK, which a driver reads as a statement that
826                // executed successfully and affected zero rows. For an INSERT, UPDATE or DELETE
827                // that is a claim the write completed, and the caller carries on believing it
828                // landed. The log line admitted it was "indistinguishable from a successful
829                // no-op" — which is precisely why it cannot be the answer.
830                //
831                // Fail closed with an ERR packet instead, the same shape the backend-error arm
832                // below uses. 1105 (ER_UNKNOWN_ERROR) rather than 1205: nothing here timed out,
833                // so advertising a retryable condition would be wrong.
834                warn!(
835                    "MySQL: no response action produced for query {:?} \
836                     (decision=fail_closed_no_action); replying with an ERR packet",
837                    query
838                );
839                let message = crate::utils::WireFailure::Unavailable.prefixed_text();
840                self.record_stats(None, Some(message.len() as u64), None, Some(1))
841                    .await;
842                finish_query(
843                    results
844                        .error(ErrorKind::ER_UNKNOWN_ERROR, message.as_bytes())
845                        .await,
846                    close_requested,
847                )
848            }
849            Err(e) => {
850                // Report the failure as a MySQL ERR packet instead of a silent empty OK, so
851                // the client sees something rather than an unexplained success. The ERR packet
852                // carries the error number and the SQLSTATE that goes with it, which is what
853                // lets a driver classify the failure instead of guessing from a message.
854                //
855                // Overload gets 1205 (SQLSTATE HY000), the code every MySQL driver already
856                // treats as "transient, safe to retry", rather than 1105 which reads as a
857                // permanent server fault.
858                let overloaded = crate::llm::is_overload_error(&e);
859                let message = crate::utils::WireFailure::classify(&e).prefixed_text();
860                let kind = if overloaded {
861                    ErrorKind::ER_LOCK_WAIT_TIMEOUT
862                } else {
863                    ErrorKind::ER_UNKNOWN_ERROR
864                };
865                // Non-fatal: a wire fallback (ERR packet) is still delivered and the
866                // connection continues.
867                Log::new(Some(&self.status_tx))
868                    .warn(format!("MySQL replying with error: {}", message));
869                self.record_stats(None, Some(message.len() as u64), None, Some(1))
870                    .await;
871                results.error(kind, message.as_bytes()).await
872            }
873        }
874    }
875}
876
877/// Map an LLM-supplied MySQL error number onto an `opensrv_mysql::ErrorKind`.
878///
879/// `ErrorKind::from(u16)` **panics** on any value that is not one of the ~886 codes it knows
880/// (`opensrv-mysql-0.7.0/src/errorcodes.rs:2807`). The number here comes straight out of model
881/// output, so calling it directly would let a hallucinated error code kill the connection task.
882/// We therefore accept the error numbers a model realistically produces and fall back to
883/// `ER_UNKNOWN_ERROR` (1105) for anything else.
884fn mysql_error_kind(code: u16) -> ErrorKind {
885    match code {
886        1044 => ErrorKind::ER_DBACCESS_DENIED_ERROR,
887        1045 => ErrorKind::ER_ACCESS_DENIED_ERROR,
888        1046 => ErrorKind::ER_NO_DB_ERROR,
889        1049 => ErrorKind::ER_BAD_DB_ERROR,
890        1050 => ErrorKind::ER_TABLE_EXISTS_ERROR,
891        1051 => ErrorKind::ER_BAD_TABLE_ERROR,
892        1052 => ErrorKind::ER_NON_UNIQ_ERROR,
893        1054 => ErrorKind::ER_BAD_FIELD_ERROR,
894        1062 => ErrorKind::ER_DUP_ENTRY,
895        1064 => ErrorKind::ER_PARSE_ERROR,
896        1065 => ErrorKind::ER_EMPTY_QUERY,
897        1136 => ErrorKind::ER_WRONG_VALUE_COUNT_ON_ROW,
898        1146 => ErrorKind::ER_NO_SUCH_TABLE,
899        1149 => ErrorKind::ER_SYNTAX_ERROR,
900        1216 => ErrorKind::ER_NO_REFERENCED_ROW,
901        1217 => ErrorKind::ER_ROW_IS_REFERENCED,
902        1364 => ErrorKind::ER_NO_DEFAULT_FOR_FIELD,
903        1451 => ErrorKind::ER_ROW_IS_REFERENCED_2,
904        1452 => ErrorKind::ER_NO_REFERENCED_ROW_2,
905        1690 => ErrorKind::ER_DATA_OUT_OF_RANGE,
906        other => {
907            warn!(
908                "MySQL: error code {} is not in opensrv-mysql's table, reporting 1105 ER_UNKNOWN_ERROR",
909                other
910            );
911            ErrorKind::ER_UNKNOWN_ERROR
912        }
913    }
914}
915
916/// Count the `?` parameter placeholders in a SQL statement.
917///
918/// A `?` inside a string literal, a quoted identifier or a comment is data, not a
919/// placeholder, so those regions are skipped. MySQL's lexical rules:
920///
921/// - `'…'` and `"…"` are strings (`"` is an identifier under `ANSI_QUOTES`, but either way a
922///   `?` inside it is not a placeholder), with both `\` escapes and the doubled-quote form.
923/// - `` `…` `` is a quoted identifier, escaped by doubling the backtick.
924/// - `-- ` and `#` run to end of line; `/* … */` is a block comment. `/*! … */` version
925///   comments are treated as comments too — a placeholder hidden inside one would be
926///   pathological, and under-counting there is caught by the client rather than desyncing us.
927///
928/// Exposed for `tests/server/mysql/prepared_statement_test.rs`; the project keeps unit tests
929/// out of `src/`.
930pub fn count_placeholders(sql: &str) -> usize {
931    let bytes = sql.as_bytes();
932    let mut count = 0usize;
933    let mut i = 0usize;
934
935    while i < bytes.len() {
936        match bytes[i] {
937            q @ (b'\'' | b'"' | b'`') => {
938                let escapes_with_backslash = q != b'`';
939                i += 1;
940                while i < bytes.len() {
941                    if escapes_with_backslash && bytes[i] == b'\\' {
942                        i += 2;
943                        continue;
944                    }
945                    if bytes[i] == q {
946                        // A doubled quote stays inside the literal.
947                        if bytes.get(i + 1) == Some(&q) {
948                            i += 2;
949                            continue;
950                        }
951                        i += 1;
952                        break;
953                    }
954                    i += 1;
955                }
956            }
957            b'#' => {
958                i += 1;
959                while i < bytes.len() && bytes[i] != b'\n' {
960                    i += 1;
961                }
962            }
963            // MySQL requires whitespace (or end of input) after `--` for it to be a comment;
964            // `a--b` is two unary minuses.
965            b'-' if bytes.get(i + 1) == Some(&b'-')
966                && bytes.get(i + 2).is_none_or(|c| c.is_ascii_whitespace()) =>
967            {
968                i += 2;
969                while i < bytes.len() && bytes[i] != b'\n' {
970                    i += 1;
971                }
972            }
973            b'/' if bytes.get(i + 1) == Some(&b'*') => {
974                i += 2;
975                while i < bytes.len() {
976                    if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
977                        i += 2;
978                        break;
979                    }
980                    i += 1;
981                }
982            }
983            b'?' => {
984                count += 1;
985                i += 1;
986            }
987            _ => i += 1,
988        }
989    }
990
991    count
992}
993
994/// Turn a successfully-written response into a connection teardown when the LLM asked for
995/// `close_this_connection`. opensrv-mysql ends the session when the shim returns an error.
996fn finish_query(result: io::Result<()>, close_requested: bool) -> io::Result<()> {
997    match result {
998        Ok(()) if close_requested => Err(io::Error::new(
999            io::ErrorKind::ConnectionAborted,
1000            "MySQL connection closed by close_this_connection action",
1001        )),
1002        other => other,
1003    }
1004}
1005
1006/// Send a result set to the client
1007async fn send_result_set<'a, W: tokio::io::AsyncWrite + Send + Unpin>(
1008    results: QueryResultWriter<'a, W>,
1009    columns: Vec<serde_json::Value>,
1010    rows: Vec<serde_json::Value>,
1011) -> io::Result<()> {
1012    // Parse column definitions
1013    let mut cols = Vec::new();
1014    for col_def in &columns {
1015        let name = col_def
1016            .get("name")
1017            .and_then(|v| v.as_str())
1018            .unwrap_or("column");
1019
1020        let col_type = col_def
1021            .get("type")
1022            .and_then(|v| v.as_str())
1023            .unwrap_or("VARCHAR");
1024
1025        let mysql_type = match col_type.to_uppercase().as_str() {
1026            "INT" | "INTEGER" => ColumnType::MYSQL_TYPE_LONG,
1027            "BIGINT" => ColumnType::MYSQL_TYPE_LONGLONG,
1028            "SMALLINT" => ColumnType::MYSQL_TYPE_SHORT,
1029            "TINYINT" => ColumnType::MYSQL_TYPE_TINY,
1030            "FLOAT" => ColumnType::MYSQL_TYPE_FLOAT,
1031            "DOUBLE" => ColumnType::MYSQL_TYPE_DOUBLE,
1032            "DECIMAL" => ColumnType::MYSQL_TYPE_DECIMAL,
1033            "DATE" => ColumnType::MYSQL_TYPE_DATE,
1034            "TIME" => ColumnType::MYSQL_TYPE_TIME,
1035            "DATETIME" | "TIMESTAMP" => ColumnType::MYSQL_TYPE_DATETIME,
1036            "BLOB" | "BINARY" => ColumnType::MYSQL_TYPE_BLOB,
1037            "TEXT" => ColumnType::MYSQL_TYPE_STRING,
1038            _ => ColumnType::MYSQL_TYPE_VAR_STRING,
1039        };
1040
1041        cols.push(Column {
1042            table: "".to_string(),
1043            column: name.to_string(),
1044            coltype: mysql_type,
1045            colflags: ColumnFlags::empty(),
1046        });
1047    }
1048
1049    // Start the result set
1050    let mut row_writer = results.start(&cols).await?;
1051
1052    // Write rows. Every row is written to exactly `cols.len()` cells: a short row is padded
1053    // with NULLs and a long one is truncated. opensrv-mysql returns `InvalidData` from
1054    // `end_row()` for a short row, and that error ends the *session*, so a model that
1055    // miscounted one row used to kill the connection rather than produce one odd row.
1056    // PostgreSQL's handler has always padded; this matches it.
1057    for row_data in &rows {
1058        let Some(row_values) = row_data.as_array() else {
1059            warn!("MySQL: skipping row that is not an array: {}", row_data);
1060            continue;
1061        };
1062        if row_values.len() != cols.len() {
1063            warn!(
1064                "MySQL: row has {} values for {} columns; padding/truncating",
1065                row_values.len(),
1066                cols.len()
1067            );
1068        }
1069        for (idx, col) in cols.iter().enumerate() {
1070            let value = row_values.get(idx).unwrap_or(&serde_json::Value::Null);
1071            write_cell(&mut row_writer, col, value)?;
1072        }
1073        row_writer.end_row().await?;
1074    }
1075
1076    // Finish the result set
1077    row_writer.finish().await
1078}
1079
1080/// Write one cell, encoded the way its declared column type requires.
1081///
1082/// This is only load-bearing for the **binary** protocol (`COM_STMT_EXECUTE`, i.e. every
1083/// prepared statement). In the text protocol opensrv-mysql writes every cell as a
1084/// length-encoded string whatever the column says, so handing it a `String` was fine. In the
1085/// binary protocol it encodes according to `Column::coltype` and returns `io::Error` for a
1086/// Rust type it cannot write as that type — and `String`/`&[u8]` is rejected by every numeric
1087/// and every temporal column (`opensrv-mysql-0.7.0/src/value/encode.rs`). So the protocol's
1088/// own advertised example, `{"name": "id", "type": "INT"}`, ended the session on
1089/// `conn.exec(...)` while working perfectly on `conn.query(...)`.
1090///
1091/// A value that cannot be represented as its column's type is sent as SQL NULL with a WARN.
1092/// That is deliberately not an error: a single odd cell must not cost the connection, and a
1093/// silently coerced wrong number would be worse than an explicit absence.
1094fn write_cell<W: tokio::io::AsyncWrite + Send + Unpin>(
1095    row: &mut RowWriter<'_, W>,
1096    column: &Column,
1097    value: &serde_json::Value,
1098) -> io::Result<()> {
1099    if value.is_null() {
1100        // A real SQL NULL: the NULL bitmap in binary, 0xFB in text. It used to be written as
1101        // the four-character string "NULL", which every client read as data.
1102        return row.write_col(None::<String>);
1103    }
1104
1105    /// Send NULL and say why, rather than ending the session over one cell.
1106    macro_rules! unrepresentable {
1107        ($row:expr, $column:expr, $value:expr) => {{
1108            warn!(
1109                "MySQL: value {} cannot be sent as column '{}' ({:?}); sending NULL",
1110                $value, $column.column, $column.coltype
1111            );
1112            $row.write_col(None::<String>)
1113        }};
1114    }
1115
1116    match column.coltype {
1117        // opensrv's `i32` encoder covers TINY/SHORT/INT24/LONG (and range-checks); its `i64`
1118        // encoder accepts LONGLONG only.
1119        ColumnType::MYSQL_TYPE_TINY
1120        | ColumnType::MYSQL_TYPE_SHORT
1121        | ColumnType::MYSQL_TYPE_INT24
1122        | ColumnType::MYSQL_TYPE_LONG => {
1123            match json_to_i64(value).and_then(|n| i32::try_from(n).ok()) {
1124                Some(n) => row.write_col(n),
1125                None => unrepresentable!(row, column, value),
1126            }
1127        }
1128        ColumnType::MYSQL_TYPE_LONGLONG => match json_to_i64(value) {
1129            Some(n) => row.write_col(n),
1130            None => unrepresentable!(row, column, value),
1131        },
1132        ColumnType::MYSQL_TYPE_FLOAT => match json_to_f64(value) {
1133            Some(n) => row.write_col(n as f32),
1134            None => unrepresentable!(row, column, value),
1135        },
1136        ColumnType::MYSQL_TYPE_DOUBLE => match json_to_f64(value) {
1137            Some(n) => row.write_col(n),
1138            None => unrepresentable!(row, column, value),
1139        },
1140        ColumnType::MYSQL_TYPE_DATE => match value.as_str().and_then(parse_date) {
1141            Some(d) => row.write_col(d),
1142            None => unrepresentable!(row, column, value),
1143        },
1144        ColumnType::MYSQL_TYPE_DATETIME | ColumnType::MYSQL_TYPE_TIMESTAMP => {
1145            match value.as_str().and_then(parse_datetime) {
1146                Some(dt) => row.write_col(dt),
1147                None => unrepresentable!(row, column, value),
1148            }
1149        }
1150        ColumnType::MYSQL_TYPE_TIME => match value.as_str().and_then(parse_time) {
1151            Some(d) => row.write_col(d),
1152            None => unrepresentable!(row, column, value),
1153        },
1154        // Every remaining type this server maps to (VAR_STRING, STRING, BLOB, DECIMAL) is one
1155        // opensrv's `&[u8]` encoder accepts in both protocols.
1156        _ => row.write_col(json_to_mysql_string(value)),
1157    }
1158}
1159
1160/// Coerce a JSON value to an integer, accepting the shapes a model actually produces.
1161fn json_to_i64(value: &serde_json::Value) -> Option<i64> {
1162    match value {
1163        serde_json::Value::Number(n) => n
1164            .as_i64()
1165            .or_else(|| n.as_u64().and_then(|u| i64::try_from(u).ok()))
1166            .or_else(|| {
1167                n.as_f64()
1168                    .filter(|f| f.fract() == 0.0 && f.is_finite())
1169                    .map(|f| f as i64)
1170            }),
1171        serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
1172        serde_json::Value::Bool(b) => Some(*b as i64),
1173        _ => None,
1174    }
1175}
1176
1177fn json_to_f64(value: &serde_json::Value) -> Option<f64> {
1178    match value {
1179        serde_json::Value::Number(n) => n.as_f64(),
1180        serde_json::Value::String(s) => s.trim().parse::<f64>().ok(),
1181        serde_json::Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
1182        _ => None,
1183    }
1184}
1185
1186fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
1187    chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
1188}
1189
1190fn parse_datetime(s: &str) -> Option<chrono::NaiveDateTime> {
1191    let s = s.trim();
1192    for format in [
1193        "%Y-%m-%d %H:%M:%S%.f",
1194        "%Y-%m-%d %H:%M:%S",
1195        "%Y-%m-%dT%H:%M:%S%.f",
1196        "%Y-%m-%dT%H:%M:%S",
1197    ] {
1198        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, format) {
1199            return Some(dt);
1200        }
1201    }
1202    parse_date(s).map(|d| d.into())
1203}
1204
1205/// `HH:MM:SS[.ffffff]` as a duration since midnight.
1206///
1207/// Bounded at 34 days because `opensrv-mysql`'s `Duration` encoder contains
1208/// `assert!(d <= 34)` — a longer value would *panic* the connection task rather than fail it,
1209/// and the value comes from model output.
1210fn parse_time(s: &str) -> Option<std::time::Duration> {
1211    let s = s.trim();
1212    let (whole, fraction) = match s.split_once('.') {
1213        Some((w, f)) => (w, f),
1214        None => (s, ""),
1215    };
1216    let mut parts = whole.split(':');
1217    let hours: u64 = parts.next()?.parse().ok()?;
1218    let minutes: u64 = parts.next()?.parse().ok()?;
1219    let seconds: u64 = parts.next().unwrap_or("0").parse().ok()?;
1220    if parts.next().is_some() || minutes > 59 || seconds > 59 {
1221        return None;
1222    }
1223    let micros: u32 = if fraction.is_empty() {
1224        0
1225    } else {
1226        let padded = format!("{:0<6}", &fraction[..fraction.len().min(6)]);
1227        padded.parse().ok()?
1228    };
1229    let total_secs = hours * 3600 + minutes * 60 + seconds;
1230    if total_secs / (24 * 3600) > 34 {
1231        return None;
1232    }
1233    Some(std::time::Duration::new(total_secs, micros * 1_000))
1234}
1235
1236/// Convert JSON value to MySQL string representation.
1237///
1238/// `Null` is handled by the caller (`write_cell`) as a real SQL NULL and never reaches here;
1239/// the empty string is the safe answer if it ever does.
1240fn json_to_mysql_string(v: &serde_json::Value) -> String {
1241    match v {
1242        serde_json::Value::Null => String::new(),
1243        serde_json::Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
1244        serde_json::Value::Number(n) => n.to_string(),
1245        serde_json::Value::String(s) => s.clone(),
1246        serde_json::Value::Array(_) | serde_json::Value::Object(_) => v.to_string(),
1247    }
1248}