Skip to main content

netget/server/mssql/
mod.rs

1//! MSSQL server implementation using manual TDS protocol
2pub mod actions;
3
4use crate::llm::action_helper::call_llm;
5use crate::llm::actions::protocol_trait::ActionResult;
6use crate::llm::ollama_client::OllamaClient;
7use crate::logging::emit::Log;
8use crate::protocol::Event;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use actions::{MssqlProtocol, MSSQL_QUERY_EVENT};
12use anyhow::Result;
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf};
16use tokio::net::{TcpListener, TcpStream};
17use tokio::sync::{mpsc, Mutex};
18use tracing::{debug, error, info, trace, warn};
19
20/// TDS packet size we advertise in the login ENVCHANGE and honour when writing.
21const TDS_PACKET_SIZE: usize = 4096;
22
23/// The write half of one connection, shared between the session loop and the dashboard's
24/// peer-command task (`server::peer_support`).
25///
26/// Every handler below that used to take `&mut TcpStream` only ever *wrote* through it — the
27/// reads all happen in the top-level loop — so they take this instead. The mutex is what keeps
28/// an injected write from landing between the continuation packets of a TDS message.
29type SharedWrite = Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>;
30
31/// How long to wait for a peer's first TDS packet after it has connected.
32///
33/// TDS is client-speaks-first: PRELOGIN is the first packet and the server says nothing before
34/// it. `tiberius`, `sqlcmd` and every ADO.NET driver send it inside the connect path, so a peer
35/// that has connected and sent nothing has begun no session — and that is the state an
36/// unauthenticated flood lives in.
37const FIRST_PACKET_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
38
39/// How long to wait for a *further* packet once the peer has logged in.
40///
41/// SQL Server has no idle-disconnect default to copy, so the number is argued the same way
42/// MySQL's and PostgreSQL's are: this server holds nothing a reconnect would lose — no tables,
43/// no temp tables, no open transaction, no state the model carries between batches — so a
44/// pooled connection reaped here costs one transparent reconnect. Ten minutes is far above any
45/// connection pool's keepalive interval and still bounds the hold.
46const IDLE_BETWEEN_PACKETS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
47
48/// Concurrent connections this server admits.
49const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
50
51/// A peer over [`MAX_CONNECTIONS`] gets a plain close, which is what a real SQL Server does.
52///
53/// TDS has no message a server may send to a client still in PRELOGIN: every server packet is a
54/// typed response to a request, and a TABULAR_RESULT carrying an ERROR token read in that state
55/// is a framing violation, not a diagnosis. The refusal is in the log instead, tagged
56/// `decision=fail_closed_connection_cap`.
57const CONNECTION_CAP_REFUSAL: &[u8] = b"";
58
59/// SQL Server's own "Login failed for user '%.*ls'." error. Severity 14 is what a real
60/// instance sends, and every driver already maps it to an authentication failure.
61const LOGIN_FAILED_ERROR: u32 = 18456;
62
63/// Generic user-defined error number. Anything >= 50000 is a user error, which is what an
64/// LLM-driven server's failures actually are.
65const MSSQL_ERROR_GENERIC: u32 = 50000;
66
67/// "Cannot process request. Not enough resources to process request." On the standard
68/// transient-error list SqlClient's built-in retry logic keys off, so a client backs off and
69/// retries instead of surfacing the failure to the application.
70const MSSQL_ERROR_NOT_ENOUGH_RESOURCES: u32 = 49918;
71
72/// MSSQL server implementation
73pub struct MssqlServer {
74    llm_client: OllamaClient,
75    app_state: Arc<AppState>,
76    _status_tx: mpsc::UnboundedSender<String>,
77    server_id: Option<crate::state::ServerId>,
78}
79
80impl MssqlServer {
81    /// Create a new MSSQL server
82    pub fn new(
83        llm_client: OllamaClient,
84        app_state: Arc<AppState>,
85        status_tx: mpsc::UnboundedSender<String>,
86        server_id: Option<crate::state::ServerId>,
87    ) -> Self {
88        Self {
89            llm_client,
90            app_state,
91            _status_tx: status_tx,
92            server_id,
93        }
94    }
95
96    /// Spawn MSSQL server with LLM integration
97    pub async fn spawn_with_llm_actions(
98        listen_addr: SocketAddr,
99        llm_client: OllamaClient,
100        app_state: Arc<AppState>,
101        status_tx: mpsc::UnboundedSender<String>,
102        _send_first: bool,
103        server_id: crate::state::ServerId,
104    ) -> Result<SocketAddr> {
105        let listener = TcpListener::bind(listen_addr).await?;
106        let actual_addr = listener.local_addr()?;
107
108        Log::new(Some(&status_tx)).info(format!("MSSQL server listening on {}", actual_addr));
109
110        let server = Arc::new(MssqlServer::new(
111            llm_client,
112            app_state.clone(),
113            status_tx.clone(),
114            Some(server_id),
115        ));
116
117        let status_tx_clone = status_tx.clone();
118        let task_registrar = app_state.clone();
119
120        // Spawn the accept loop
121        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
122        let accept_handle = tokio::spawn(async move {
123            loop {
124                match crate::server::accept_bounded::accept_bounded(
125                    &listener,
126                    &limiter,
127                    CONNECTION_CAP_REFUSAL,
128                    "MSSQL",
129                    Some(&status_tx),
130                )
131                .await
132                {
133                    Ok((stream, addr, permit)) => {
134                        Log::new(Some(&status_tx)).info(format!("MSSQL connection from {}", addr));
135
136                        let connection_id =
137                            ConnectionId::new(app_state.get_next_unified_id().await);
138                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
139
140                        // Track the connection
141                        if let Some(server_id) = server.server_id {
142                            use crate::state::server::{
143                                ConnectionState as ServerConnectionState, ConnectionStatus,
144                                ProtocolConnectionInfo,
145                            };
146                            let now = crate::utils::clock::Instant::now();
147                            let conn_state = ServerConnectionState {
148                                id: connection_id,
149                                remote_addr: addr,
150                                local_addr: local_addr_conn,
151                                bytes_sent: 0,
152                                bytes_received: 0,
153                                packets_sent: 0,
154                                packets_received: 0,
155                                last_activity: now,
156                                status: ConnectionStatus::Active,
157                                status_changed_at: now,
158                                protocol_info: ProtocolConnectionInfo::empty(),
159                            };
160                            server
161                                .app_state
162                                .add_connection_to_server(server_id, conn_state)
163                                .await;
164                        }
165
166                        let handler = MssqlHandler::new(
167                            connection_id,
168                            server.llm_client.clone(),
169                            server.app_state.clone(),
170                            status_tx.clone(),
171                            server.server_id,
172                            addr,
173                        );
174
175                        // Tracked, not detached: stop_server must abort this task too.
176                        let task_owner = app_state.clone();
177                        task_owner
178                            .spawn_server_task(server_id, async move {
179                                // Held for the life of the session, so the cap counts live
180                                // connections rather than accepts.
181                                let _permit = permit;
182                                if let Err(e) = handler.handle_connection(stream).await {
183                                    error!("MSSQL connection error: {:?}", e);
184                                }
185                            })
186                            .await;
187                    }
188                    Err(e) => {
189                        Log::new(Some(&status_tx)).error(format!("MSSQL accept error: {}", e));
190                    }
191                }
192            }
193        });
194
195        // Register the accept loop so stop_server can abort it and release the port.
196        task_registrar
197            .register_server_task(server_id, accept_handle)
198            .await;
199
200        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
201        Ok(actual_addr)
202    }
203}
204
205/// MSSQL connection handler
206pub struct MssqlHandler {
207    connection_id: ConnectionId,
208    llm_client: OllamaClient,
209    app_state: Arc<AppState>,
210    status_tx: mpsc::UnboundedSender<String>,
211    #[allow(dead_code)]
212    server_id: Option<crate::state::ServerId>,
213    #[allow(dead_code)]
214    remote_addr: SocketAddr,
215    /// MSSQL protocol handler for action execution
216    protocol: Arc<MssqlProtocol>,
217}
218
219impl MssqlHandler {
220    pub fn new(
221        connection_id: ConnectionId,
222        llm_client: OllamaClient,
223        app_state: Arc<AppState>,
224        status_tx: mpsc::UnboundedSender<String>,
225        server_id: Option<crate::state::ServerId>,
226        remote_addr: SocketAddr,
227    ) -> Self {
228        let protocol = Arc::new(MssqlProtocol::new(
229            connection_id,
230            app_state.clone(),
231            status_tx.clone(),
232        ));
233
234        Self {
235            connection_id,
236            llm_client,
237            app_state,
238            status_tx,
239            server_id,
240            remote_addr,
241            protocol,
242        }
243    }
244
245    /// Run the connection, then always mark it closed in `AppState`.
246    ///
247    /// The socket is split here rather than inside the loop, because the write half has two
248    /// owners: the loop's own TDS replies and the dashboard's peer-command task. Registration
249    /// happens **before the first read** — TDS is client-speaks-first, so this server says
250    /// nothing until PRELOGIN arrives, and a `manual` rule can then park the login (or any
251    /// later batch) for minutes. The operator has to be able to reach, or hang up, a
252    /// connection that is waiting on their own answer.
253    async fn handle_connection(self, stream: TcpStream) -> Result<()> {
254        let server_id = self.server_id;
255        let connection_id = self.connection_id;
256        let app_state = self.app_state.clone();
257
258        // Owning split, never a clone: the write half outlives this stack frame inside the
259        // peer-command task.
260        let (mut reader, write_half) = tokio::io::split(stream);
261        let write_half: SharedWrite = Arc::new(Mutex::new(write_half));
262
263        if let Some(server_id) = server_id {
264            let peer_rx = crate::server::peer_support::register_peer_channel(
265                &app_state,
266                server_id,
267                connection_id.as_u32(),
268            )
269            .await;
270            crate::server::peer_support::spawn_peer_command_task(
271                peer_rx,
272                self.protocol.clone(),
273                app_state.clone(),
274                server_id,
275                connection_id.as_u32(),
276                write_half.clone(),
277                self.status_tx.clone(),
278            );
279        }
280
281        let result = self.run_session(&mut reader, &write_half).await;
282
283        // Every exit path — EOF, a read timeout, a refused login, an attention, a write error,
284        // an injected disconnect — lands here, and none of them may `?` past it. Dropping the
285        // handle also ends the peer command task, which releases its clone of the write half;
286        // the explicit shutdown makes the FIN immediate rather than waiting on it.
287        if let Some(server_id) = server_id {
288            app_state
289                .remove_peer_handle(server_id, connection_id.as_u32())
290                .await;
291        }
292        let _ = write_half.lock().await.shutdown().await;
293
294        if let Some(server_id) = server_id {
295            app_state
296                .close_connection_on_server(server_id, connection_id)
297                .await;
298        }
299
300        result
301    }
302
303    /// The read/dispatch/reply loop. Reads come off `reader`; every write goes through the
304    /// shared `write_half`, which the peer-command task also holds.
305    async fn run_session(
306        &self,
307        reader: &mut ReadHalf<TcpStream>,
308        write_half: &SharedWrite,
309    ) -> Result<()> {
310        info!("MSSQL connection established");
311
312        // Set only by an `mssql_login_ack`. Nothing else in this loop may set it, so the
313        // admission decision has exactly one source.
314        //
315        // Without this flag the `mssql_login` decision was skippable: the loop dispatched
316        // packet types independently of one another, so a peer that simply never sent a
317        // LOGIN7 and opened with a SQL Batch (0x01) or an RPC (0x03) got its query answered
318        // and `mssql_login` never fired at all. An instruction like "only allow the user
319        // `reporting`" was then enforced against every client that asked to be checked and
320        // none that did not. Real drivers always log in; a hostile peer has no reason to.
321        let mut authenticated = false;
322
323        // Handle TDS protocol negotiation and queries
324        loop {
325            // Both reads below are bounded by this deadline, and nothing else in the loop is.
326            // The LLM round-trip that decides a login or answers a batch, and a `manual` rule
327            // parking either for a human (`src/state/intercepts.rs`, 300s by default), happen
328            // after a whole packet has been read — outside the deadline by construction, so
329            // neither can be cut short. What is bounded is a peer holding the connection while
330            // sending nothing, and one that announces a packet length and then stalls.
331            let read_timeout = if authenticated {
332                IDLE_BETWEEN_PACKETS_TIMEOUT
333            } else {
334                FIRST_PACKET_READ_TIMEOUT
335            };
336
337            // Read TDS packet header (8 bytes)
338            let header = match self.read_tds_header(reader, read_timeout).await {
339                Ok(h) => h,
340                Err(e) => {
341                    debug!("Error reading TDS header: {}", e);
342                    break;
343                }
344            };
345
346            if header.length < 8 {
347                debug!("Invalid TDS packet length: {}", header.length);
348                break;
349            }
350
351            // Read packet data
352            let data_len = header.length - 8;
353            let mut data = vec![0u8; data_len as usize];
354            match tokio::time::timeout(read_timeout, reader.read_exact(&mut data)).await {
355                Ok(read) => read?,
356                Err(_) => {
357                    debug!(
358                        "MSSQL connection {} announced a {}-byte packet and stalled for {}s \
359                         without delivering it; closing",
360                        self.connection_id,
361                        header.length,
362                        read_timeout.as_secs()
363                    );
364                    break;
365                }
366            };
367
368            // Only the *sent* side was counted before, so every MSSQL connection in the
369            // dashboard rail showed a growing ↑ against a permanently zero ↓, and
370            // `last_activity` was never refreshed by an inbound packet.
371            if let Some(server_id) = self.server_id {
372                self.app_state
373                    .update_connection_stats(
374                        server_id,
375                        self.connection_id,
376                        Some(u64::from(header.length)),
377                        None,
378                        Some(1),
379                        None,
380                    )
381                    .await;
382            }
383
384            trace!(
385                "TDS packet type: 0x{:02x}, length: {}",
386                header.packet_type,
387                header.length
388            );
389
390            match header.packet_type {
391                0x12 => {
392                    // Pre-Login
393                    debug!("Received Pre-Login packet (length: {})", header.length);
394                    self.send_prelogin_response(write_half).await?;
395                    debug!("Pre-Login response sent");
396                }
397                0x10 => {
398                    // TDS7/TDS8 Login
399                    debug!("Received Login packet");
400                    if !self.handle_login(write_half, &data).await? {
401                        // Refused. The ERROR token is already on the wire; TDS has no way to
402                        // continue a session whose login failed, so close.
403                        break;
404                    }
405                    authenticated = true;
406                }
407                0x01 | 0x03 if !authenticated => {
408                    // A statement before the session was admitted. Answer with the same 18456
409                    // an outright refusal produces and close, rather than serving the query:
410                    // the model was never asked about this peer, so nothing has admitted it.
411                    Log::new(Some(&self.status_tx)).warn(
412                        "MSSQL statement refused (decision=fail_closed_not_logged_in): the \
413                         client sent a batch or RPC before any LOGIN7"
414                            .to_string(),
415                    );
416                    self.send_error(
417                        write_half,
418                        LOGIN_FAILED_ERROR,
419                        "Login failed. The login is from an untrusted domain and cannot be \
420                         used with Windows authentication.",
421                        14,
422                    )
423                    .await?;
424                    break;
425                }
426                0x01 => {
427                    // SQL Batch
428                    debug!("Received SQL Batch packet");
429                    let query = self.parse_sql_batch(&data)?;
430                    debug!("SQL Query: {}", query);
431                    if self.handle_query(write_half, &query).await? {
432                        break;
433                    }
434                }
435                0x03 => {
436                    // RPC Request (sp_executesql, sp_prepare, etc.)
437                    debug!("Received RPC Request");
438                    let query = self.parse_rpc_request(&data)?;
439                    if !query.is_empty() {
440                        debug!("RPC Query: {}", query);
441                        if self.handle_query(write_half, &query).await? {
442                            break;
443                        }
444                    } else {
445                        debug!("RPC call without extractable query (ignoring)");
446                        // Send empty result set for RPCs we can't parse
447                        self.send_empty_result(write_half).await?;
448                    }
449                }
450                0x0E => {
451                    // Bulk Load
452                    debug!("Received Bulk Load (not implemented)");
453                    self.send_error(write_half, 40002, "Bulk load not supported", 16)
454                        .await?;
455                }
456                0x07 => {
457                    // Attention (cancel)
458                    debug!("Received Attention signal");
459                    break;
460                }
461                _ => {
462                    debug!("Unknown TDS packet type: 0x{:02x}", header.packet_type);
463                    self.send_error(write_half, 40002, "Unknown packet type", 16)
464                        .await?;
465                }
466            }
467        }
468
469        Ok(())
470    }
471
472    /// Read TDS packet header (8 bytes)
473    async fn read_tds_header(
474        &self,
475        reader: &mut ReadHalf<TcpStream>,
476        read_timeout: std::time::Duration,
477    ) -> Result<TdsHeader> {
478        let mut header_bytes = [0u8; 8];
479        match tokio::time::timeout(read_timeout, reader.read_exact(&mut header_bytes)).await {
480            Ok(read) => read?,
481            Err(_) => {
482                anyhow::bail!(
483                    "peer sent nothing for {}s; closing idle connection",
484                    read_timeout.as_secs()
485                )
486            }
487        };
488
489        Ok(TdsHeader {
490            packet_type: header_bytes[0],
491            status: header_bytes[1],
492            length: u16::from_be_bytes([header_bytes[2], header_bytes[3]]),
493            spid: u16::from_be_bytes([header_bytes[4], header_bytes[5]]),
494            packet_id: header_bytes[6],
495            window: header_bytes[7],
496        })
497    }
498
499    /// Send Pre-Login response
500    async fn send_prelogin_response(&self, write_half: &SharedWrite) -> Result<()> {
501        // Simplified Pre-Login response
502        // Version: 16.0.0.0 (SQL Server 2022)
503        // Encryption: NOT_SUP (0x02)
504        let mut response = Vec::new();
505
506        // Calculate offsets (all token headers = 3 tokens * 5 bytes + 1 terminator = 16 bytes)
507        let header_size = 16u16;
508        let version_offset = header_size; // 16 (0x10)
509        let version_length = 6u16;
510        let encryption_offset = version_offset + version_length; // 22 (0x16)
511        let encryption_length = 1u16;
512        let threadid_offset = encryption_offset + encryption_length; // 23 (0x17)
513        let threadid_length = 4u16;
514
515        // Version token (0x00)
516        response.push(0x00);
517        response.extend_from_slice(&version_offset.to_be_bytes()); // Offset: 0x00, 0x10
518        response.extend_from_slice(&version_length.to_be_bytes()); // Length: 0x00, 0x06
519
520        // Encryption token (0x01)
521        response.push(0x01);
522        response.extend_from_slice(&encryption_offset.to_be_bytes()); // Offset: 0x00, 0x16
523        response.extend_from_slice(&encryption_length.to_be_bytes()); // Length: 0x00, 0x01
524
525        // ThreadID token (0x03)
526        response.push(0x03);
527        response.extend_from_slice(&threadid_offset.to_be_bytes()); // Offset: 0x00, 0x17
528        response.extend_from_slice(&threadid_length.to_be_bytes()); // Length: 0x00, 0x04
529
530        // Terminator
531        response.push(0xFF);
532
533        // Version data (16.0.0.0)
534        response.extend_from_slice(&[0x10, 0x00, 0x00, 0x00, 0x00, 0x00]);
535
536        // Encryption: ENCRYPT_NOT_SUP (0x02) - encryption not supported
537        response.push(0x02);
538
539        // ThreadID: 0
540        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
541
542        self.send_tds_packet(write_half, 0x04, &response).await
543    }
544
545    /// Ask the model whether to admit this login, and refuse unless it says yes.
546    ///
547    /// Every TDS Login used to be accepted unconditionally: there was no `mssql_login` event
548    /// and no action that could decline one, so an operator instruction like "only allow the
549    /// user `reporting`" could not be enforced and the model was never consulted. Authentication
550    /// decided by default is the pattern the root CLAUDE.md calls the most dangerous here.
551    ///
552    /// Returns `Ok(true)` when the session may proceed. Accept requires an explicit
553    /// `mssql_login_ack`; a model rejection (`mssql_error_response`), an answer containing
554    /// neither, and a backend failure all refuse — and are kept apart in the log, because the
555    /// wire cannot distinguish them beyond the error number.
556    async fn handle_login(&self, write_half: &SharedWrite, data: &[u8]) -> Result<bool> {
557        let (username, database, app_name) = parse_login7(data);
558
559        let event = Event::new(
560            &actions::MSSQL_LOGIN_EVENT,
561            serde_json::json!({
562                "username": username,
563                "database": database,
564                "app_name": app_name,
565            }),
566        );
567
568        let server_id = self
569            .server_id
570            .unwrap_or_else(|| crate::state::ServerId::new(0));
571
572        let llm_result = call_llm(
573            &self.llm_client,
574            &self.app_state,
575            server_id,
576            Some(self.connection_id),
577            &event,
578            self.protocol.as_ref(),
579        )
580        .await;
581
582        match llm_result {
583            Ok(result) => {
584                for r in &result.protocol_results {
585                    if let ActionResult::Custom { name, data } = r {
586                        match name.as_str() {
587                            "mssql_login_ack" => {
588                                let db = data
589                                    .get("database")
590                                    .and_then(|v| v.as_str())
591                                    .unwrap_or("master");
592                                Log::new(Some(&self.status_tx)).info(format!(
593                                    "MSSQL login accepted for user '{}' (decision=model_accept)",
594                                    username
595                                ));
596                                self.send_login_response_with_database(write_half, db)
597                                    .await?;
598                                return Ok(true);
599                            }
600                            "mssql_error" => {
601                                let number = data
602                                    .get("error_number")
603                                    .and_then(|v| v.as_u64())
604                                    .unwrap_or(LOGIN_FAILED_ERROR as u64)
605                                    as u32;
606                                let message = data
607                                    .get("message")
608                                    .and_then(|v| v.as_str())
609                                    .unwrap_or("Login failed.");
610                                Log::new(Some(&self.status_tx)).info(format!(
611                                    "MSSQL login refused for user '{}' (decision=model_reject): {}",
612                                    username, message
613                                ));
614                                self.send_error(write_half, number, message, 14).await?;
615                                return Ok(false);
616                            }
617                            _ => {}
618                        }
619                    }
620                }
621
622                // The handler ran and produced neither verdict. Refuse: silence is not consent
623                // for an authentication decision.
624                Log::new(Some(&self.status_tx)).warn(format!(
625                    "MSSQL login refused for user '{}' (decision=fail_closed_no_action): the \
626                     handler produced no mssql_login_ack",
627                    username
628                ));
629                self.send_error(
630                    write_half,
631                    LOGIN_FAILED_ERROR,
632                    &format!("Login failed for user '{}'.", username),
633                    14,
634                )
635                .await?;
636                Ok(false)
637            }
638            Err(e) => {
639                // netget could not reach a decision. Refuse, and tell the client only a
640                // category — never the backend error, which names the model and the URL.
641                let failure = crate::utils::WireFailure::classify(&e);
642                error!(
643                    "MSSQL login for user '{}' decision=fail_closed_llm_error category={:?}: {:#}",
644                    username, failure, e
645                );
646                Log::new(Some(&self.status_tx)).warn(format!(
647                    "MSSQL login refused for user '{}' (decision=fail_closed_llm_error)",
648                    username
649                ));
650                self.send_error(write_half, LOGIN_FAILED_ERROR, failure.text(), 14)
651                    .await?;
652                Ok(false)
653            }
654        }
655    }
656
657    /// Send the LOGINACK/ENVCHANGE/DONE sequence that admits a session.
658    ///
659    /// Only reachable from `handle_login` once the model has explicitly accepted.
660    async fn send_login_response_with_database(
661        &self,
662        write_half: &SharedWrite,
663        db_name: &str,
664    ) -> Result<()> {
665        let mut response = Vec::new();
666
667        // ENVCHANGE: Database context
668
669        let db_name_utf16: Vec<u8> = db_name
670            .encode_utf16()
671            .flat_map(|c| c.to_le_bytes())
672            .collect();
673
674        let mut envchange_db = Vec::new();
675        envchange_db.push(0xE3); // ENVCHANGE token
676        let envchange_db_len = 1 + 1 + db_name_utf16.len() + 1 + db_name_utf16.len();
677        envchange_db.extend_from_slice(&(envchange_db_len as u16).to_le_bytes());
678        envchange_db.push(0x01); // Type: Database
679        envchange_db.push(db_name.len() as u8); // New value length (in characters)
680        envchange_db.extend_from_slice(&db_name_utf16);
681        envchange_db.push(db_name.len() as u8); // Old value length (in characters)
682        envchange_db.extend_from_slice(&db_name_utf16);
683        response.extend_from_slice(&envchange_db);
684
685        // ENVCHANGE: Language (us_english)
686        let lang = "us_english";
687        let lang_utf16: Vec<u8> = lang.encode_utf16().flat_map(|c| c.to_le_bytes()).collect();
688
689        let mut envchange_lang = Vec::new();
690        envchange_lang.push(0xE3); // ENVCHANGE token
691        let envchange_lang_len = 1 + 1 + lang_utf16.len() + 1 + 0; // old value is empty
692        envchange_lang.extend_from_slice(&(envchange_lang_len as u16).to_le_bytes());
693        envchange_lang.push(0x02); // Type: Language
694        envchange_lang.push(lang.len() as u8); // New value length (in characters)
695        envchange_lang.extend_from_slice(&lang_utf16);
696        envchange_lang.push(0x00); // Old value length (empty)
697        response.extend_from_slice(&envchange_lang);
698
699        // ENVCHANGE: Packet size ("4096" as string)
700        let pkt_size_new = "4096";
701        let pkt_size_old = "512"; // Default packet size
702        let pkt_size_new_utf16: Vec<u8> = pkt_size_new
703            .encode_utf16()
704            .flat_map(|c| c.to_le_bytes())
705            .collect();
706        let pkt_size_old_utf16: Vec<u8> = pkt_size_old
707            .encode_utf16()
708            .flat_map(|c| c.to_le_bytes())
709            .collect();
710
711        let mut envchange_pkt = Vec::new();
712        envchange_pkt.push(0xE3); // ENVCHANGE token
713        let envchange_pkt_len = 1 + 1 + pkt_size_new_utf16.len() + 1 + pkt_size_old_utf16.len();
714        envchange_pkt.extend_from_slice(&(envchange_pkt_len as u16).to_le_bytes());
715        envchange_pkt.push(0x04); // Type: Packet size
716        envchange_pkt.push(pkt_size_new.len() as u8); // New value length (in characters)
717        envchange_pkt.extend_from_slice(&pkt_size_new_utf16);
718        envchange_pkt.push(pkt_size_old.len() as u8); // Old value length (in characters)
719        envchange_pkt.extend_from_slice(&pkt_size_old_utf16);
720        response.extend_from_slice(&envchange_pkt);
721
722        // INFO message
723        let msg = "Login succeeded";
724        let msg_utf16: Vec<u8> = msg.encode_utf16().flat_map(|c| c.to_le_bytes()).collect();
725
726        response.push(0xAB); // INFO token
727        let info_len = 4 + 1 + 1 + 2 + msg_utf16.len() + 1 + 1 + 4;
728        response.extend_from_slice(&(info_len as u16).to_le_bytes());
729        response.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // Error number
730        response.push(0x01); // State
731        response.push(0x00); // Class (severity)
732        response.extend_from_slice(&(msg.len() as u16).to_le_bytes()); // Message length (character count)
733        response.extend_from_slice(&msg_utf16);
734        response.push(0x00); // Server name length
735        response.push(0x00); // Procedure name length
736        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Line number
737
738        // DONE token
739        response.push(0xFD); // DONE token
740        response.extend_from_slice(&[0x00, 0x00]); // Status
741        response.extend_from_slice(&[0x00, 0x00]); // CurCmd
742        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // DoneRowCount
743
744        self.send_tds_packet(write_half, 0x04, &response).await
745    }
746
747    /// Parse SQL Batch packet
748    fn parse_sql_batch(&self, data: &[u8]) -> Result<String> {
749        // SQL Batch format:
750        // - Header (22 bytes for TDS 7.4+)
751        // - SQL text (Unicode UTF-16LE)
752
753        if data.len() < 22 {
754            return Ok(String::new());
755        }
756
757        // Skip header, extract SQL text
758        let sql_bytes = &data[22..];
759
760        // Decode UTF-16LE
761        let sql_u16: Vec<u16> = sql_bytes
762            .chunks_exact(2)
763            .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
764            .collect();
765
766        Ok(String::from_utf16_lossy(&sql_u16).trim().to_string())
767    }
768
769    /// Recover the SQL text from an RPC Request packet.
770    ///
771    /// RPC parameter marshalling is not decoded; the statement is found heuristically by
772    /// looking for a SQL keyword in the UTF-16LE payload. Two properties matter and both were
773    /// wrong before:
774    ///
775    /// * **One decode, not one per offset.** This used to slide a 2000-byte window forward two
776    ///   bytes at a time, decoding and upper-casing it at every position: ~32,000 windows for a
777    ///   maximum-size TDS packet, each allocating two strings and running eight substring
778    ///   searches, with no `.await` anywhere to yield the worker. A single 64 KB packet was
779    ///   seconds of CPU on a tokio worker thread, repeatable by one connection. Every window sat
780    ///   on the same UTF-16 code-unit grid (the step is 2 bytes and so is a code unit), so
781    ///   decoding the payload once covers exactly the same text.
782    /// * **`to_ascii_uppercase`, never `to_uppercase`.** The offset of the keyword was taken
783    ///   from the upper-cased string and then used to slice the *original* one. Unicode
784    ///   case mapping changes byte length - U+FB01 `fi` upper-cases to the two bytes `FI`, U+0149
785    ///   `ʼn` to the three bytes `ʼN` - so a payload carrying one such character ahead of the
786    ///   keyword sliced the original mid-character and panicked. That is reachable from the
787    ///   wire, and the panic skipped `handle_connection`'s teardown, leaving the connection
788    ///   `Active` in the dashboard for the life of the process. ASCII case folding never
789    ///   changes a byte's width, so the offset stays valid in both strings.
790    fn parse_rpc_request(&self, data: &[u8]) -> Result<String> {
791        /// Keywords a statement may open with, searched for case-insensitively.
792        const SQL_KEYWORDS: [&str; 7] = [
793            "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER",
794        ];
795
796        if data.len() < 10 {
797            return Ok(String::new());
798        }
799
800        let preview_len = std::cmp::min(data.len(), 200);
801        let hex_preview: String = data[..preview_len]
802            .iter()
803            .map(|b| format!("{:02x}", b))
804            .collect::<Vec<_>>()
805            .join(" ");
806        debug!(
807            "RPC data preview (first {} bytes): {}",
808            preview_len, hex_preview
809        );
810
811        let units: Vec<u16> = data
812            .chunks_exact(2)
813            .map(|c| u16::from_le_bytes([c[0], c[1]]))
814            .collect();
815        let text = String::from_utf16_lossy(&units);
816        let upper = text.to_ascii_uppercase();
817
818        // The earliest keyword anywhere in the payload, not the first keyword in list order:
819        // a packet mentioning DELETE in a parameter name before its actual SELECT should not
820        // have the SELECT truncated away.
821        let Some(pos) = SQL_KEYWORDS.iter().filter_map(|kw| upper.find(kw)).min() else {
822            return Ok(String::new());
823        };
824
825        // `upper` is ASCII-folded `text`, so byte lengths and char boundaries are identical
826        // and `pos` indexes the same character in both.
827        let sql: String = text[pos..]
828            .chars()
829            .take_while(|c| {
830                c.is_ascii() && (*c == '\n' || *c == '\r' || *c == '\t' || !c.is_ascii_control())
831            })
832            .collect();
833        let sql = sql.trim().to_string();
834        if sql.is_empty() {
835            return Ok(String::new());
836        }
837
838        debug!("Extracted SQL from RPC at offset {}: {}", pos, sql);
839        Ok(sql)
840    }
841
842    /// Handle SQL query with LLM.
843    ///
844    /// Returns `true` when the LLM asked to close the connection.
845    async fn handle_query(&self, write_half: &SharedWrite, query: &str) -> Result<bool> {
846        trace!("Calling LLM for MSSQL query: {}", query);
847
848        // Create query event
849        let event = Event::new(
850            &MSSQL_QUERY_EVENT,
851            serde_json::json!({
852                "query": query,
853            }),
854        );
855
856        let server_id = self
857            .server_id
858            .unwrap_or_else(|| crate::state::ServerId::new(0));
859
860        let llm_result = call_llm(
861            &self.llm_client,
862            &self.app_state,
863            server_id,
864            Some(self.connection_id),
865            &event,
866            self.protocol.as_ref(),
867        )
868        .await;
869
870        match llm_result {
871            Ok(execution_result) => {
872                let close_requested = execution_result
873                    .protocol_results
874                    .iter()
875                    .any(|r| matches!(r, ActionResult::CloseConnection));
876                let mut responded = false;
877
878                // Process action results to find MSSQL responses
879                for result in execution_result.protocol_results {
880                    if responded {
881                        // TDS is strictly one token stream per statement, so only the first
882                        // response-producing action can reach the wire. Say which ones were
883                        // dropped rather than discarding them silently.
884                        if let ActionResult::Custom { name, .. } = &result {
885                            warn!(
886                                "MSSQL: query already answered, ignoring extra action result '{}'",
887                                name
888                            );
889                        }
890                        continue;
891                    }
892                    match result {
893                        ActionResult::Custom { name, data } => match name.as_str() {
894                            "mssql_query_response" => {
895                                let columns = data
896                                    .get("columns")
897                                    .and_then(|v| v.as_array())
898                                    .cloned()
899                                    .unwrap_or_default();
900                                let rows = data
901                                    .get("rows")
902                                    .and_then(|v| v.as_array())
903                                    .cloned()
904                                    .unwrap_or_default();
905
906                                self.send_result_set(write_half, columns, rows).await?;
907                                responded = true;
908                            }
909                            "mssql_error" => {
910                                let error_number = data
911                                    .get("error_number")
912                                    .and_then(|v| v.as_u64())
913                                    .unwrap_or(50000)
914                                    as u32;
915                                let message = data
916                                    .get("message")
917                                    .and_then(|v| v.as_str())
918                                    .unwrap_or("Unknown error");
919                                let severity =
920                                    data.get("severity").and_then(|v| v.as_u64()).unwrap_or(16)
921                                        as u8;
922
923                                // The model chose to refuse this statement. Tagged so an
924                                // operator can tell a deliberate refusal from netget failing
925                                // to obtain one; the two are indistinguishable on the wire.
926                                Log::new(Some(&self.status_tx)).info(format!(
927                                    "MSSQL query decision=model_reject error={} severity={}",
928                                    error_number, severity
929                                ));
930                                self.send_error(write_half, error_number, message, severity)
931                                    .await?;
932                                responded = true;
933                            }
934                            "mssql_ok" => {
935                                let rows_affected = data
936                                    .get("rows_affected")
937                                    .and_then(|v| v.as_u64())
938                                    .unwrap_or(0);
939
940                                self.send_done(write_half, rows_affected).await?;
941                                responded = true;
942                            }
943                            other => {
944                                warn!(
945                                    "MSSQL: no wire encoding for action result '{}', ignoring",
946                                    other
947                                );
948                            }
949                        },
950                        _ => {}
951                    }
952                }
953
954                if !responded {
955                    // TDS clients block until a DONE or an ERROR arrives, so something must be
956                    // sent. It must not be a bare DONE: in SQL that reads as "the statement ran
957                    // and matched nothing", which is a meaningful *successful* answer. Nobody
958                    // answered this query, so the fail-closed token is ERROR.
959                    //
960                    // An explicit `close_this_connection` is the one exception - that is the
961                    // model deciding, not the model going quiet - so it keeps the DONE it has
962                    // always produced and is tagged as its own decision.
963                    if close_requested {
964                        Log::new(Some(&self.status_tx))
965                            .info("MSSQL query decision=model_close (closing without a result)");
966                        self.send_done(write_half, 0).await?;
967                    } else {
968                        let message = crate::utils::WireFailure::Unavailable.prefixed_text();
969                        Log::new(Some(&self.status_tx)).warn(format!(
970                            "MSSQL query decision=fail_closed_no_action; replying error {}: {}",
971                            MSSQL_ERROR_GENERIC, message
972                        ));
973                        warn!("MSSQL: no response action produced for query {:?}", query);
974                        self.send_error(write_half, MSSQL_ERROR_GENERIC, message, 16)
975                            .await?;
976                    }
977                }
978
979                Ok(close_requested)
980            }
981            Err(e) => {
982                // A TDS ERROR token is raised by every driver as a SqlException on the
983                // statement that produced it, so it cannot be mistaken for a result set - and
984                // an empty result set is a meaningful answer in SQL, which is why silence or a
985                // bare DONE would be worse than useless here.
986                //
987                // 49918 ("Cannot process request. Not enough resources") is on the standard
988                // transient-error list that SqlClient's own retry logic keys off, so capacity
989                // exhaustion gets retried rather than surfaced to the application. 50000 is
990                // the generic user-defined error number for everything else.
991                let overloaded = crate::llm::is_overload_error(&e);
992                let number = if overloaded {
993                    MSSQL_ERROR_NOT_ENOUGH_RESOURCES
994                } else {
995                    MSSQL_ERROR_GENERIC
996                };
997                let message = crate::utils::WireFailure::classify(&e).prefixed_text();
998                // Non-fatal: a wire fallback (TDS ERROR token) is still delivered and the
999                // connection stays open, so this is recovered rather than a hard failure.
1000                // `decision=fail_closed_llm_error` is never `model_reject`: the model said
1001                // nothing at all here. The full error goes to the log only.
1002                error!("MSSQL: LLM error answering query {:?}: {:#}", query, e);
1003                Log::new(Some(&self.status_tx)).warn(format!(
1004                    "MSSQL query decision=fail_closed_llm_error ({}); replying error {number}: {message}",
1005                    if overloaded { "overloaded" } else { "unavailable" }
1006                ));
1007                self.send_error(write_half, number, message, 16).await?;
1008                Ok(false)
1009            }
1010        }
1011    }
1012
1013    /// Send result set
1014    ///
1015    /// Every column is emitted as one of TDS's *nullable* (variable-length) types, because
1016    /// those are the only ones whose COLMETADATA carries a length byte and whose row values
1017    /// carry a length prefix - which is the shape this encoder produces. The previous mapping
1018    /// handed out FIXEDLENTYPE codes (INT4TYPE 0x38, INT8TYPE 0x7F, BITTYPE 0x32, FLT4TYPE
1019    /// 0x3B) while still writing length bytes, so anything other than a string column put
1020    /// malformed tokens on the wire.
1021    async fn send_result_set(
1022        &self,
1023        write_half: &SharedWrite,
1024        columns: Vec<serde_json::Value>,
1025        rows: Vec<serde_json::Value>,
1026    ) -> Result<()> {
1027        debug!(
1028            "send_result_set called: {} columns, {} rows",
1029            columns.len(),
1030            rows.len()
1031        );
1032
1033        let col_types: Vec<TdsColumnType> = columns
1034            .iter()
1035            .map(|col| {
1036                TdsColumnType::from_name(
1037                    col.get("type")
1038                        .and_then(|v| v.as_str())
1039                        .unwrap_or("NVARCHAR"),
1040                )
1041            })
1042            .collect();
1043
1044        let mut response = Vec::new();
1045
1046        // COLMETADATA token
1047        response.push(0x81);
1048        response.extend_from_slice(&(columns.len() as u16).to_le_bytes());
1049
1050        for (col, col_type) in columns.iter().zip(&col_types) {
1051            let col_name = col.get("name").and_then(|v| v.as_str()).unwrap_or("column");
1052
1053            response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // UserType
1054                                                                   // Flags is a little-endian USHORT and fNullable is bit 0, so the bytes are
1055                                                                   // 01 00. This used to be `[0x00, 0x02]`, i.e. bit 9 - which no TDS flag
1056                                                                   // occupies, so a real client (tiberius) rejected every result set with
1057                                                                   // "column metadata: invalid flags". It went unnoticed because NetGet's own
1058                                                                   // MSSQL client could not complete the handshake at all.
1059            response.extend_from_slice(&[0x01, 0x00]); // Flags: fNullable
1060            col_type.write_type_info(&mut response);
1061
1062            // Column name is a B_VARCHAR: length in UTF-16 code units, then UTF-16LE.
1063            let name_units: Vec<u16> = col_name.encode_utf16().take(128).collect();
1064            response.push(name_units.len() as u8);
1065            for unit in &name_units {
1066                response.extend_from_slice(&unit.to_le_bytes());
1067            }
1068        }
1069
1070        // ROW tokens
1071        for row in &rows {
1072            response.push(0xD1); // ROW token
1073
1074            let row_values = row.as_array().cloned().unwrap_or_default();
1075            // TDS requires exactly one value per described column: pad short rows with NULL
1076            // and drop extras rather than desynchronising the token stream.
1077            for (idx, col_type) in col_types.iter().enumerate() {
1078                let value = row_values.get(idx).unwrap_or(&serde_json::Value::Null);
1079                col_type.write_value(&mut response, value);
1080            }
1081        }
1082
1083        // DONE token. DONE_COUNT (0x0010) is what makes the client believe the row count.
1084        response.push(0xFD);
1085        response.extend_from_slice(&0x0010u16.to_le_bytes()); // Status: final + count valid
1086        response.extend_from_slice(&0x00C1u16.to_le_bytes()); // CurCmd: SELECT
1087        response.extend_from_slice(&(rows.len() as u64).to_le_bytes());
1088
1089        debug!("Sending result set: {} bytes", response.len());
1090        self.send_tds_packet(write_half, 0x04, &response).await
1091    }
1092
1093    /// Send empty result set (just DONE token)
1094    async fn send_empty_result(&self, write_half: &SharedWrite) -> Result<()> {
1095        let mut response = Vec::new();
1096
1097        // DONE token
1098        response.push(0xFD);
1099        response.extend_from_slice(&[0x00, 0x00]); // Status
1100        response.extend_from_slice(&[0x00, 0x00]); // CurCmd
1101        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // DoneRowCount
1102
1103        self.send_tds_packet(write_half, 0x04, &response).await
1104    }
1105
1106    /// Send error response
1107    async fn send_error(
1108        &self,
1109        write_half: &SharedWrite,
1110        error_number: u32,
1111        message: &str,
1112        severity: u8,
1113    ) -> Result<()> {
1114        let mut response = Vec::new();
1115
1116        // ERROR token (0xAA)
1117        response.push(0xAA);
1118
1119        let msg_u16: Vec<u16> = message.encode_utf16().collect();
1120        let msg_bytes: Vec<u8> = msg_u16.iter().flat_map(|c| c.to_le_bytes()).collect();
1121
1122        let token_len = 4 + 1 + 1 + 2 + msg_bytes.len() + 1 + 1 + 4;
1123        response.extend_from_slice(&(token_len as u16).to_le_bytes());
1124
1125        response.extend_from_slice(&error_number.to_le_bytes());
1126        response.push(0x01); // State
1127        response.push(severity); // Class (severity)
1128        response.extend_from_slice(&(msg_u16.len() as u16).to_le_bytes());
1129        response.extend_from_slice(&msg_bytes);
1130        response.push(0x00); // Server name length
1131        response.push(0x00); // Procedure name length
1132        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Line number
1133
1134        // DONE token
1135        response.push(0xFD);
1136        response.extend_from_slice(&[0x00, 0x00]); // Status
1137        response.extend_from_slice(&[0x00, 0x00]); // CurCmd
1138        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
1139
1140        self.send_tds_packet(write_half, 0x04, &response).await
1141    }
1142
1143    /// Send DONE token
1144    async fn send_done(&self, write_half: &SharedWrite, rows_affected: u64) -> Result<()> {
1145        let mut response = Vec::new();
1146
1147        response.push(0xFD); // DONE token
1148                             // DONE_COUNT (0x0010) must be set or the client ignores DoneRowCount entirely, which
1149                             // silently discarded the `rows_affected` value the LLM supplied via mssql_ok_response.
1150        response.extend_from_slice(&0x0010u16.to_le_bytes()); // Status: final + count valid
1151        response.extend_from_slice(&0x00C1u16.to_le_bytes()); // CurCmd
1152        response.extend_from_slice(&rows_affected.to_le_bytes());
1153
1154        self.send_tds_packet(write_half, 0x04, &response).await
1155    }
1156
1157    /// Send a TDS message, split across packets of the negotiated size.
1158    ///
1159    /// The header length field is a `u16`, so a single oversized write used to wrap around and
1160    /// emit a packet whose declared length was far shorter than its payload. TDS solves this
1161    /// with continuation packets: every packet but the last carries status 0x00, the last
1162    /// carries 0x01 (EOM).
1163    ///
1164    /// The whole message is framed first and written under **one** lock on the shared write
1165    /// half. Locking per packet would let an injected write from the dashboard's peer task
1166    /// land between two continuation packets of the same message, which no TDS client can
1167    /// resynchronise from.
1168    async fn send_tds_packet(
1169        &self,
1170        write_half: &SharedWrite,
1171        packet_type: u8,
1172        data: &[u8],
1173    ) -> Result<()> {
1174        let max_payload = TDS_PACKET_SIZE - 8;
1175        let mut packet_id: u8 = 1;
1176        let mut offset = 0;
1177        let mut framed: Vec<u8> = Vec::with_capacity(data.len() + 8);
1178        let mut packets: u64 = 0;
1179
1180        loop {
1181            let end = std::cmp::min(offset + max_payload, data.len());
1182            let chunk = &data[offset..end];
1183            let is_last = end == data.len();
1184
1185            framed.push(packet_type); // Type
1186            framed.push(if is_last { 0x01 } else { 0x00 }); // Status: EOM on the last packet
1187            framed.extend_from_slice(&((8 + chunk.len()) as u16).to_be_bytes()); // Length
1188            framed.extend_from_slice(&[0x00, 0x00]); // SPID
1189            framed.push(packet_id); // PacketID
1190            framed.push(0x00); // Window
1191            framed.extend_from_slice(chunk);
1192            packets += 1;
1193
1194            if is_last {
1195                break;
1196            }
1197            offset = end;
1198            packet_id = packet_id.wrapping_add(1).max(1);
1199        }
1200
1201        write_counted(
1202            write_half,
1203            &framed,
1204            packets,
1205            &self.app_state,
1206            self.server_id,
1207            self.connection_id,
1208        )
1209        .await?;
1210        Ok(())
1211    }
1212}
1213
1214/// Write one framed TDS message to the shared write half and count it.
1215///
1216/// `packets` is the number of TDS packets inside `data`, which is why this takes a count
1217/// rather than assuming one: a message over [`TDS_PACKET_SIZE`] is several packets in a
1218/// single write, and the rail's `↑` packet counter has always counted TDS packets.
1219///
1220/// The guard is dropped before `update_connection_stats`, so nothing awaits `AppState` — let
1221/// alone an LLM call — while holding the write half.
1222async fn write_counted(
1223    write_half: &SharedWrite,
1224    data: &[u8],
1225    packets: u64,
1226    app_state: &AppState,
1227    server_id: Option<crate::state::ServerId>,
1228    connection_id: ConnectionId,
1229) -> std::io::Result<()> {
1230    {
1231        let mut writer = write_half.lock().await;
1232        writer.write_all(data).await?;
1233        writer.flush().await?;
1234    }
1235    if let Some(server_id) = server_id {
1236        app_state
1237            .update_connection_stats(
1238                server_id,
1239                connection_id,
1240                None,
1241                Some(data.len() as u64),
1242                None,
1243                Some(packets),
1244            )
1245            .await;
1246    }
1247    Ok(())
1248}
1249
1250/// TDS packet header
1251#[allow(dead_code)]
1252struct TdsHeader {
1253    packet_type: u8,
1254    #[allow(dead_code)]
1255    status: u8,
1256    length: u16,
1257    #[allow(dead_code)]
1258    spid: u16,
1259    #[allow(dead_code)]
1260    packet_id: u8,
1261    #[allow(dead_code)]
1262    window: u8,
1263}
1264
1265/// Maximum bytes of NVARCHAR payload a single column value may carry.
1266///
1267/// 4000 UTF-16 code units is the largest non-MAX `nvarchar`. Declaring `nvarchar(max)` instead
1268/// would oblige us to emit PLP-chunked row values, which this encoder does not do.
1269const NVARCHAR_MAX_BYTES: usize = 8000;
1270
1271/// The TDS wire representation chosen for one result-set column.
1272#[derive(Clone, Copy, PartialEq, Eq)]
1273enum TdsColumnType {
1274    /// INTNTYPE (0x26) with the given byte width: 1, 2, 4 or 8.
1275    Int(u8),
1276    /// BITNTYPE (0x68).
1277    Bit,
1278    /// FLTNTYPE (0x6D), 8-byte double.
1279    Float,
1280    /// NVARCHARTYPE (0xE7), UTF-16LE with a USHORT length prefix.
1281    NVarChar,
1282}
1283
1284impl TdsColumnType {
1285    /// Map an LLM-supplied SQL type name onto a wire type. Unknown names become NVARCHAR.
1286    fn from_name(type_name: &str) -> Self {
1287        match type_name.trim().to_uppercase().as_str() {
1288            "TINYINT" => TdsColumnType::Int(1),
1289            "SMALLINT" => TdsColumnType::Int(2),
1290            "INT" | "INTEGER" => TdsColumnType::Int(4),
1291            "BIGINT" => TdsColumnType::Int(8),
1292            "BIT" | "BOOL" | "BOOLEAN" => TdsColumnType::Bit,
1293            "FLOAT" | "REAL" | "DOUBLE" | "DECIMAL" | "NUMERIC" | "MONEY" => TdsColumnType::Float,
1294            _ => TdsColumnType::NVarChar,
1295        }
1296    }
1297
1298    /// Write the COLMETADATA TYPE_INFO for this column.
1299    fn write_type_info(&self, out: &mut Vec<u8>) {
1300        match self {
1301            TdsColumnType::Int(width) => {
1302                out.push(0x26); // INTNTYPE
1303                out.push(*width);
1304            }
1305            TdsColumnType::Bit => {
1306                out.push(0x68); // BITNTYPE
1307                out.push(0x01);
1308            }
1309            TdsColumnType::Float => {
1310                out.push(0x6D); // FLTNTYPE
1311                out.push(0x08);
1312            }
1313            TdsColumnType::NVarChar => {
1314                out.push(0xE7); // NVARCHARTYPE
1315                out.extend_from_slice(&(NVARCHAR_MAX_BYTES as u16).to_le_bytes());
1316                out.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00]); // COLLATION
1317            }
1318        }
1319    }
1320
1321    /// Write one row value, including its length prefix. JSON `null` becomes a real SQL NULL.
1322    fn write_value(&self, out: &mut Vec<u8>, value: &serde_json::Value) {
1323        match self {
1324            TdsColumnType::Int(width) => {
1325                match value_as_i64(value) {
1326                    Some(v) => {
1327                        out.push(*width);
1328                        match width {
1329                            1 => out.push(v as u8),
1330                            2 => out.extend_from_slice(&(v as i16).to_le_bytes()),
1331                            4 => out.extend_from_slice(&(v as i32).to_le_bytes()),
1332                            _ => out.extend_from_slice(&v.to_le_bytes()),
1333                        }
1334                    }
1335                    // NULL, or a value that is not an integer at all.
1336                    None => out.push(0x00),
1337                }
1338            }
1339            TdsColumnType::Bit => match value_as_bool(value) {
1340                Some(b) => {
1341                    out.push(0x01);
1342                    out.push(u8::from(b));
1343                }
1344                None => out.push(0x00),
1345            },
1346            TdsColumnType::Float => match value_as_f64(value) {
1347                Some(f) => {
1348                    out.push(0x08);
1349                    out.extend_from_slice(&f.to_le_bytes());
1350                }
1351                None => out.push(0x00),
1352            },
1353            TdsColumnType::NVarChar => {
1354                if value.is_null() {
1355                    // 0xFFFF is the NVARCHAR NULL marker. Writing the text "NULL" here, as the
1356                    // previous encoder did, made every NULL arrive as a four-character string.
1357                    out.extend_from_slice(&0xFFFFu16.to_le_bytes());
1358                    return;
1359                }
1360                let text = json_to_string(value);
1361                let mut bytes: Vec<u8> = Vec::new();
1362                for unit in text.encode_utf16() {
1363                    if bytes.len() + 2 > NVARCHAR_MAX_BYTES {
1364                        break;
1365                    }
1366                    bytes.extend_from_slice(&unit.to_le_bytes());
1367                }
1368                out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
1369                out.extend_from_slice(&bytes);
1370            }
1371        }
1372    }
1373}
1374
1375fn value_as_i64(value: &serde_json::Value) -> Option<i64> {
1376    match value {
1377        serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
1378        serde_json::Value::Bool(b) => Some(i64::from(*b)),
1379        serde_json::Value::String(s) => s.parse().ok(),
1380        _ => None,
1381    }
1382}
1383
1384fn value_as_f64(value: &serde_json::Value) -> Option<f64> {
1385    match value {
1386        serde_json::Value::Number(n) => n.as_f64(),
1387        serde_json::Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
1388        serde_json::Value::String(s) => s.parse().ok(),
1389        _ => None,
1390    }
1391}
1392
1393fn value_as_bool(value: &serde_json::Value) -> Option<bool> {
1394    match value {
1395        serde_json::Value::Bool(b) => Some(*b),
1396        serde_json::Value::Number(n) => n.as_i64().map(|v| v != 0),
1397        serde_json::Value::String(s) => match s.to_ascii_lowercase().as_str() {
1398            "true" | "1" | "yes" => Some(true),
1399            "false" | "0" | "no" => Some(false),
1400            _ => None,
1401        },
1402        _ => None,
1403    }
1404}
1405
1406/// Convert a JSON value to its NVARCHAR text form.
1407fn json_to_string(value: &serde_json::Value) -> String {
1408    match value {
1409        serde_json::Value::Null => String::new(),
1410        serde_json::Value::Bool(b) => if *b { "1" } else { "0" }.to_string(),
1411        serde_json::Value::Number(n) => n.to_string(),
1412        serde_json::Value::String(s) => s.clone(),
1413        serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
1414    }
1415}
1416
1417/// Pull the username, database and application name out of a LOGIN7 packet body.
1418///
1419/// MS-TDS 2.2.6.4: a fixed 36-byte prelude, then a table of `(offset, length)` pairs in
1420/// UTF-16 code units, each offset measured from the start of the LOGIN7 data. Only three of
1421/// the fields matter for an access decision, and the password is deliberately *not* among
1422/// them — it is not needed to decide, and putting a credential into an event would send it
1423/// to the model and into the event log.
1424///
1425/// Everything is best-effort: a truncated or malformed packet yields empty strings rather
1426/// than an error, because the caller's job is to refuse a login it cannot justify, and an
1427/// unparseable login is exactly that. It must never be the reason a login is *granted*.
1428#[cfg(not(target_arch = "wasm32"))]
1429fn parse_login7(data: &[u8]) -> (String, String, String) {
1430    /// Offsets of the (offset, length) pairs within the LOGIN7 body, per MS-TDS 2.2.6.4.
1431    const IB_USERNAME: usize = 40;
1432    const IB_APPNAME: usize = 48;
1433    const IB_DATABASE: usize = 68;
1434
1435    let field = |pair_at: usize| -> String {
1436        // Each pair is two little-endian u16s: offset, then length in UTF-16 characters.
1437        let Some(bytes) = data.get(pair_at..pair_at + 4) else {
1438            return String::new();
1439        };
1440        let offset = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
1441        let chars = u16::from_le_bytes([bytes[2], bytes[3]]) as usize;
1442        if chars == 0 {
1443            return String::new();
1444        }
1445        let Some(raw) = data.get(offset..offset + chars * 2) else {
1446            return String::new();
1447        };
1448        let units: Vec<u16> = raw
1449            .chunks_exact(2)
1450            .map(|c| u16::from_le_bytes([c[0], c[1]]))
1451            .collect();
1452        String::from_utf16_lossy(&units)
1453    };
1454
1455    (field(IB_USERNAME), field(IB_DATABASE), field(IB_APPNAME))
1456}