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}