Skip to main content

netget/server/db2/
mod.rs

1//! IBM Db2 server implementation (DRDA wire protocol).
2//!
3//! Speaks enough DRDA/DDM that a Db2 client can complete the connection handshake
4//! (EXCSAT/ACCSEC/SECCHK/ACCRDB) and execute a basic statement (EXCSQLIMM), with
5//! the LLM playing the database: it decides whether a login is accepted and what
6//! SQLCA a statement produces. The wire codec is [`drda`]; **no storage** exists —
7//! the model answers every request.
8//!
9//! **Fail closed.** An LLM outage during the security check is a refusal
10//! (SECCHKRM at severity ERROR), never an accept; a failure during a statement is
11//! an error SQLCARD, never a success. See `AGENTS.md` for what DRDA is and is not
12//! implemented here; Apache Derby's JDBC driver and IBM's CLI driver both connect to it
13//! (`tests/server/db2/real_client_test.rs`).
14
15pub mod actions;
16pub mod drda;
17
18/// How long to wait for a peer's first DRDA request after it has connected.
19///
20/// DRDA is client-speaks-first with no server greeting: a real driver sends EXCSAT the moment
21/// the socket is up, and nothing legitimate happens before it. A peer that has connected and
22/// sent nothing is holding a task and a connection slot on the strength of no request at all.
23const FIRST_REQUEST_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
24
25/// How long to wait for a *further* request once the security check has been accepted.
26///
27/// Much longer, because an authenticated Db2 session legitimately sits idle: every driver in
28/// front of one (JDBC, pureQuery, `ibm_db`) pools connections and holds them open between
29/// statements, and the pools' own idle-reap defaults are in minutes. Five minutes bounds the
30/// hold without closing sessions a real pool considers healthy — and an unauthenticated peer
31/// never reaches this bound, which is the half that matters for an attacker.
32const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
33
34/// DSSes one command may chain under its correlator (the command and its command data). Real
35/// commands chain two or three; past this the connection is dropped rather than buffered.
36const MAX_DSS_PER_COMMAND: usize = 16;
37
38/// Concurrent connections this server admits.
39const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
40
41/// A peer over [`MAX_CONNECTIONS`] gets a plain close, and that is the honest answer here.
42///
43/// Every DDM reply is a DSS carrying the *request's* correlation identifier, and this peer has
44/// sent no request — so there is no well-formed refusal to write. Bytes invented for the slot
45/// would be parsed as a reply to a request that does not exist and reported as a protocol
46/// violation, which tells the operator less than the refusal already logged at WARN with
47/// `decision=fail_closed_connection_cap`.
48const CONNECTION_CAP_REFUSAL: &[u8] = b"";
49
50use std::net::SocketAddr;
51use std::sync::Arc;
52
53use anyhow::Result;
54use tokio::io::{AsyncReadExt, AsyncWriteExt};
55use tokio::net::TcpListener;
56use tokio::sync::{mpsc, Mutex};
57use tracing::{debug, error, info, warn};
58
59use crate::llm::action_helper::call_llm;
60use crate::llm::actions::protocol_trait::ActionResult;
61use crate::llm::ollama_client::OllamaClient;
62use crate::logging::emit::Log;
63use crate::protocol::Event;
64use crate::server::connection::ConnectionId;
65use crate::server::db2::actions::{Db2Protocol, DB2_CONNECT_EVENT, DB2_QUERY_EVENT};
66use crate::state::app_state::AppState;
67
68/// Db2 server.
69pub struct Db2Server;
70
71impl Db2Server {
72    /// Spawn the Db2 server. Binds with `?` (bind failure surfaces as
73    /// `ServerStatus::Error`) and registers the accept-loop `JoinHandle` so
74    /// `stop_server` can release the socket.
75    pub async fn spawn_with_llm_actions(
76        listen_addr: SocketAddr,
77        llm_client: OllamaClient,
78        app_state: Arc<AppState>,
79        status_tx: mpsc::UnboundedSender<String>,
80        server_id: crate::state::ServerId,
81    ) -> Result<SocketAddr> {
82        let listener = TcpListener::bind(listen_addr).await?;
83        let actual_addr = listener.local_addr()?;
84
85        Log::new(Some(&status_tx)).info(format!("Db2 server listening on {}", actual_addr));
86
87        let task_registrar = app_state.clone();
88        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
89        let accept_handle = tokio::spawn(async move {
90            loop {
91                match crate::server::accept_bounded::accept_bounded(
92                    &listener,
93                    &limiter,
94                    CONNECTION_CAP_REFUSAL,
95                    "Db2",
96                    Some(&status_tx),
97                )
98                .await
99                {
100                    Ok((stream, remote_addr, permit)) => {
101                        let connection_id =
102                            ConnectionId::new(app_state.get_next_unified_id().await);
103                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
104                        debug!("Db2 connection {} from {}", connection_id, remote_addr);
105
106                        use crate::state::server::{
107                            ConnectionState as ServerConnectionState, ConnectionStatus,
108                            ProtocolConnectionInfo,
109                        };
110                        let now = crate::utils::clock::Instant::now();
111                        let conn_state = ServerConnectionState {
112                            id: connection_id,
113                            remote_addr,
114                            local_addr: local_addr_conn,
115                            bytes_sent: 0,
116                            bytes_received: 0,
117                            packets_sent: 0,
118                            packets_received: 0,
119                            last_activity: now,
120                            status: ConnectionStatus::Active,
121                            status_changed_at: now,
122                            protocol_info: ProtocolConnectionInfo::empty(),
123                        };
124                        app_state
125                            .add_connection_to_server(server_id, conn_state)
126                            .await;
127                        let _ = status_tx.send("__UPDATE_UI__".to_string());
128
129                        let mut handler = Db2Handler {
130                            connection_id,
131                            server_id,
132                            llm_client: llm_client.clone(),
133                            app_state: app_state.clone(),
134                            status_tx: status_tx.clone(),
135                            protocol: Arc::new(Db2Protocol::new(
136                                connection_id,
137                                app_state.clone(),
138                                status_tx.clone(),
139                            )),
140                            authenticated: false,
141                        };
142
143                        let conn_owner = app_state.clone();
144                        // Tracked, not detached: stop_server must abort this task too.
145                        let task_owner = app_state.clone();
146                        task_owner
147                            .spawn_server_task(server_id, async move {
148                                // Held for the life of the connection, so the cap counts live
149                                // sessions rather than accepts.
150                                let _permit = permit;
151                                if let Err(e) = handler.run(stream).await {
152                                    debug!("Db2 connection {} ended: {}", connection_id, e);
153                                }
154                                conn_owner
155                                    .close_connection_on_server(server_id, connection_id)
156                                    .await;
157                            })
158                            .await;
159                    }
160                    Err(e) => {
161                        error!("Db2 accept error: {}", e);
162                        break;
163                    }
164                }
165            }
166        });
167
168        task_registrar
169            .register_server_task(server_id, accept_handle)
170            .await;
171
172        Ok(actual_addr)
173    }
174}
175
176/// Per-connection Db2/DRDA handler.
177struct Db2Handler {
178    connection_id: ConnectionId,
179    server_id: crate::state::ServerId,
180    llm_client: OllamaClient,
181    app_state: Arc<AppState>,
182    status_tx: mpsc::UnboundedSender<String>,
183    protocol: Arc<Db2Protocol>,
184    /// Set once the security check (SECCHK) has been accepted.
185    authenticated: bool,
186}
187
188impl Db2Handler {
189    async fn run(&mut self, stream: tokio::net::TcpStream) -> Result<()> {
190        // Split read/write as the codebase requires (never clone the stream). The
191        // write half is shared through an `Arc<Mutex<..>>` so the dashboard's
192        // peer-command task can inject writes / a disconnect alongside the
193        // reader's own replies without racing on the socket.
194        let (reader, write_half) = tokio::io::split(stream);
195        let write_half = Arc::new(Mutex::new(write_half));
196
197        // Peer messaging: register a command channel for THIS connection so the
198        // dashboard's "[ message this peer ]" / "[ disconnect this peer ]" rows
199        // work. Injected actions go through the same central executor the LLM
200        // path uses. Db2's own wire verbs are correlator-bound `Custom` results,
201        // so only `close_connection` reaches the wire here (see CLAUDE.md).
202        let peer_rx = crate::server::peer_support::register_peer_channel(
203            &self.app_state,
204            self.server_id,
205            self.connection_id.as_u32(),
206        )
207        .await;
208        crate::server::peer_support::spawn_peer_command_task(
209            peer_rx,
210            self.protocol.clone(),
211            self.app_state.clone(),
212            self.server_id,
213            self.connection_id.as_u32(),
214            write_half.clone(),
215            self.status_tx.clone(),
216        );
217
218        let result = self.run_loop(reader, &write_half).await;
219
220        // Every exit path — clean EOF, a read/parse error, or an injected
221        // disconnect — releases the peer handle so the rail stops offering a
222        // dead connection. Idempotent with the peer task's own close path.
223        self.app_state
224            .remove_peer_handle(self.server_id, self.connection_id.as_u32())
225            .await;
226        result
227    }
228
229    /// The read/dispatch/reply loop. Writes go through the shared `write_half`.
230    async fn run_loop(
231        &mut self,
232        mut reader: tokio::io::ReadHalf<tokio::net::TcpStream>,
233        write_half: &Arc<Mutex<tokio::io::WriteHalf<tokio::net::TcpStream>>>,
234    ) -> Result<()> {
235        let mut buf: Vec<u8> = Vec::new();
236        let mut tmp = [0u8; 8192];
237        let mut command: Vec<drda::ParsedDss> = Vec::new();
238        // After a refused SECCHK the rest of its request chain is ignored, as Derby's server
239        // does: the refusal ends the reply chain, and the ACCRDB chained behind it gets no
240        // answer of its own. Answering it (even with RDBATHRM) after an unchained SECCHKRM is a
241        // reply nobody asked for, which IBM's CLI driver reports as a data stream syntax error.
242        let mut discarding = false;
243
244        loop {
245            // Ensure a full DSS is buffered before parsing.
246            while buf.len() < 6 || buf.len() < drda::dss_declared_len(&buf).unwrap_or(6) {
247                // The deadline wraps this `read()` and nothing else. The LLM round-trip that
248                // decides the login or the statement result, and a `manual` rule parking
249                // either for a human (`src/state/intercepts.rs`, 300s by default), both happen
250                // below once a whole DSS has arrived — so a slow answer is never cut short by
251                // a read deadline. What is bounded is a peer that holds the connection while
252                // sending nothing, or that stalls part-way through a DSS it announced.
253                let read_timeout = if self.authenticated {
254                    IDLE_BETWEEN_REQUESTS_TIMEOUT
255                } else {
256                    FIRST_REQUEST_READ_TIMEOUT
257                };
258                let n = match tokio::time::timeout(read_timeout, reader.read(&mut tmp)).await {
259                    Ok(read) => read?,
260                    Err(_) => {
261                        debug!(
262                            "Db2 connection {} sent nothing for {}s; closing idle connection",
263                            self.connection_id,
264                            read_timeout.as_secs()
265                        );
266                        return Ok(());
267                    }
268                };
269                if n == 0 {
270                    return Ok(()); // clean EOF
271                }
272                buf.extend_from_slice(&tmp[..n]);
273                if buf.len() >= 2 {
274                    if let Some(total) = drda::dss_declared_len(&buf) {
275                        if total < 6 {
276                            warn!("Db2: impossible DSS length {}, dropping connection", total);
277                            return Ok(());
278                        }
279                    }
280                }
281            }
282
283            let (parsed, consumed) = match drda::parse_dss(&buf) {
284                Ok(v) => v,
285                Err(e) => {
286                    warn!("Db2: DRDA parse error: {}", e);
287                    return Ok(());
288                }
289            };
290            buf.drain(0..consumed);
291
292            self.app_state
293                .update_connection_stats(
294                    self.server_id,
295                    self.connection_id,
296                    Some(consumed as u64),
297                    None,
298                    Some(1),
299                    None,
300                )
301                .await;
302
303            // A command is its RQSDSS plus the DSSes chained to it under the same correlator
304            // (its command data: SQLSTT, SQLDTA, SQLATTR). It is answered once, when its last
305            // DSS has arrived, and the reply is chained when that DSS was chained to a further
306            // command.
307            let continues = parsed.format & 0x50 == 0x50;
308            let chained = parsed.format & 0x40 != 0;
309            command.push(parsed);
310            if continues {
311                if command.len() > MAX_DSS_PER_COMMAND {
312                    warn!(
313                        "Db2: a command chained more than {MAX_DSS_PER_COMMAND} DSSes under one \
314                         correlator decision=refused_oversized; dropping the connection"
315                    );
316                    return Ok(());
317                }
318                continue;
319            }
320            let this = std::mem::take(&mut command);
321            if discarding {
322                debug!("Db2: ignoring the rest of a request chain after a refused SECCHK");
323                discarding = chained;
324                continue;
325            }
326            let reply = self.dispatch(&this).await;
327            let refused_login = this[0].codepoint == drda::cp::SECCHK && !self.authenticated;
328            discarding = refused_login && chained;
329            let reply = reply.map(|bytes| drda::chain_reply(bytes, chained && !refused_login));
330            if let Some(bytes) = reply {
331                self.app_state
332                    .update_connection_stats(
333                        self.server_id,
334                        self.connection_id,
335                        None,
336                        Some(bytes.len() as u64),
337                        None,
338                        Some(1),
339                    )
340                    .await;
341                let mut writer = write_half.lock().await;
342                writer.write_all(&bytes).await?;
343                writer.flush().await?;
344            }
345        }
346    }
347
348    /// Handle one DRDA command and its command data, returning the reply bytes to send (if
349    /// any).
350    async fn dispatch(&mut self, command: &[drda::ParsedDss]) -> Option<Vec<u8>> {
351        use drda::cp;
352        let req = command.first()?;
353        let data = &command[1..];
354        let corr = req.correlator;
355        debug!(
356            "Db2: DRDA command 0x{:04X} (correlator {})",
357            req.codepoint, corr
358        );
359
360        match req.codepoint {
361            cp::EXCSAT => Some(self.reply_excsatrd(corr, &req.body)),
362            cp::RDBCMM => Some(end_unit_of_work(corr, 1)),
363            cp::RDBRLLBCK => Some(end_unit_of_work(corr, 2)),
364            cp::ACCSEC => Some(self.reply_accsecrd(corr, &req.body)),
365            cp::SECCHK => Some(self.handle_secchk(corr, &req.body).await),
366            cp::ACCRDB => Some(self.reply_accrdbrm(corr, &req.body)),
367            cp::EXCSQLIMM | cp::EXCSQLSTT => {
368                // The SQL text is the SQLSTT command data chained to the command, or (this
369                // server's own byte-literal form) a parameter embedded in it.
370                let sqlstt = drda::find_param(&req.body, cp::SQLSTT).or_else(|| {
371                    data.iter()
372                        .find(|d| d.codepoint == cp::SQLSTT)
373                        .map(|d| d.body.clone())
374                });
375                match sqlstt {
376                    Some(sqlstt) => {
377                        let sql = drda::decode_sqlstt(&sqlstt);
378                        Some(self.handle_query(corr, sql).await)
379                    }
380                    // EXCSQLSTT of a prepared section: nothing was prepared here.
381                    None if req.codepoint == cp::EXCSQLSTT => Some(cmdnsprm(corr, req.codepoint)),
382                    // A bare EXCSQLIMM: its SQLSTT may still follow as a DSS of its own.
383                    None => None,
384                }
385            }
386            cp::EXCSQLSET => Some(self.session_settings(corr, data)),
387            cp::SQLSTT => {
388                let sql = drda::decode_sqlstt(&req.body);
389                Some(self.handle_query(corr, sql).await)
390            }
391            other => Some(cmdnsprm(corr, other)),
392        }
393    }
394
395    /// EXCSQLSET: the special registers a driver sets for itself on connecting (`SET CLIENT
396    /// WRKSTNNAME`, `SET CURRENT LOCALE LC_CTYPE`, ...). They are acknowledged without the
397    /// model - there is no session state here for them to change, and refusing them fails
398    /// every statement IBM's CLI driver sends after them - but only once the security check
399    /// has been accepted.
400    fn session_settings(&self, corr: u16, data: &[drda::ParsedDss]) -> Vec<u8> {
401        if !self.authenticated {
402            warn!("Db2 EXCSQLSET decision=fail_closed_not_authenticated");
403            let card = drda::sqlcard_error(-30082, "08001");
404            return drda::encode_dss(drda::DSSFMT_OBJDSS, false, corr, &card);
405        }
406        for d in data.iter().filter(|d| d.codepoint == drda::cp::SQLSTT) {
407            info!(
408                "Db2 EXCSQLSET decision=session_setting {}",
409                crate::utils::truncate_for_log(&drda::decode_sqlstt(&d.body), 200)
410            );
411        }
412        drda::encode_dss(drda::DSSFMT_OBJDSS, false, corr, &drda::sqlcard_success())
413    }
414
415    /// EXCSAT -> EXCSATRD: advertise the server's attributes.
416    fn reply_excsatrd(&self, corr: u16, body: &[u8]) -> Vec<u8> {
417        use drda::cp;
418        let mut inner = Vec::new();
419        inner.extend_from_slice(&drda::encode_scalar_str(cp::EXTNAM, "netget-db2"));
420        inner.extend_from_slice(&drda::encode_scalar_str(cp::SRVCLSNM, "QDB2/NETGET"));
421        inner.extend_from_slice(&drda::encode_scalar_str(cp::SRVNAM, "NETGET"));
422        inner.extend_from_slice(&drda::encode_scalar_str(cp::SRVRLSLV, "SQL11055"));
423        // Answer each manager the requester listed with the level served here: up to 7 for
424        // AGENT (0x1403), SQLAM (0x2407), RDB (0x240F) and SECMGR (0x1440), 0 (not supported)
425        // for the rest - UNICODEMGR among them, so DDM strings stay EBCDIC. A driver reads an
426        // empty list as a server with no SQL application manager at all.
427        let mut levels = Vec::new();
428        if let Some(asked) = drda::find_param(body, cp::MGRLVLLS) {
429            for pair in asked.chunks_exact(4) {
430                let manager = u16::from_be_bytes([pair[0], pair[1]]);
431                let level = match manager {
432                    0x1403 | 0x2407 | 0x240F | 0x1440 => {
433                        u16::from_be_bytes([pair[2], pair[3]]).min(7)
434                    }
435                    _ => 0,
436                };
437                levels.extend_from_slice(&manager.to_be_bytes());
438                levels.extend_from_slice(&level.to_be_bytes());
439            }
440        }
441        inner.extend_from_slice(&drda::encode_scalar(cp::MGRLVLLS, &levels));
442        let rd = drda::encode_object(cp::EXCSATRD, &inner);
443        drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rd)
444    }
445
446    /// ACCSEC -> ACCSECRD.
447    ///
448    /// Only the mechanisms whose SECCHK this server can read are accepted: USRIDPWD (3) and
449    /// USRIDONL (4), which carry USRID and PASSWORD in the clear. Any other - an encrypted one
450    /// such as EUSRIDPWD (9), whose SECCHK carries only an encrypted SECTKN - is answered with
451    /// the list this server supports and SECCHKCD 0x01 (mechanism not supported), so the
452    /// requester can retry with one of them. Accepting a mechanism the server cannot process
453    /// would put a login in front of the model whose user id it never sees; IBM's CLI driver
454    /// asks for EUSRIDPWD by default.
455    fn reply_accsecrd(&self, corr: u16, body: &[u8]) -> Vec<u8> {
456        use drda::cp;
457        let requested = drda::find_param(body, cp::SECMEC).unwrap_or_else(|| vec![0x00, 0x03]);
458        let supported = matches!(requested.as_slice(), [0x00, 0x03] | [0x00, 0x04]);
459        let mut inner = if supported {
460            drda::encode_scalar(cp::SECMEC, &requested)
461        } else {
462            warn!(
463                "Db2 ACCSEC decision=refused_secmec: the requester asked for security mechanism \
464                 {requested:02X?}; offering USRIDPWD (3) and USRIDONL (4)"
465            );
466            drda::encode_scalar(cp::SECMEC, &[0x00, 0x03, 0x00, 0x04])
467        };
468        if !supported {
469            inner.extend_from_slice(&drda::encode_scalar(cp::SECCHKCD, &[0x01]));
470        }
471        let rd = drda::encode_object(cp::ACCSECRD, &inner);
472        drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rd)
473    }
474
475    /// SECCHK -> SECCHKRM: the LLM decides accept/reject. Fail-closed: an LLM
476    /// outage is a refusal (severity ERROR), distinct in the logs from a model
477    /// denial.
478    async fn handle_secchk(&mut self, corr: u16, body: &[u8]) -> Vec<u8> {
479        use drda::cp;
480        let user_id = drda::find_param(body, cp::USRID)
481            .map(|b| drda::ebcdic_to_ascii(&b))
482            .unwrap_or_default();
483        let rdb_name = drda::find_param(body, cp::RDBNAM)
484            .map(|b| drda::ebcdic_to_ascii(&b))
485            .unwrap_or_default();
486        let has_password = drda::find_param(body, cp::PASSWORD)
487            .map(|b| !b.is_empty())
488            .unwrap_or(false);
489
490        let event = Event::new(
491            &DB2_CONNECT_EVENT,
492            serde_json::json!({
493                "user_id": user_id,
494                "rdb_name": rdb_name,
495                "has_password": has_password,
496            }),
497        );
498
499        match call_llm(
500            &self.llm_client,
501            &self.app_state,
502            self.server_id,
503            Some(self.connection_id),
504            &event,
505            self.protocol.as_ref(),
506        )
507        .await
508        {
509            Ok(result) => match first_custom(&result.protocol_results) {
510                Some(("db2_accept_connection", _)) => {
511                    self.authenticated = true;
512                    info!("Db2 SECCHK decision=model_accept user={user_id} rdb={rdb_name}");
513                    let _ = self
514                        .status_tx
515                        .send(format!("→ Db2 login accepted for {user_id}"));
516                    secchkrm(corr, drda::svrcod::INFO, drda::secchkcd::SUCCESS)
517                }
518                Some(("db2_reject_connection", data)) => {
519                    let code = match data.get("sec_check_code").and_then(|v| v.as_str()) {
520                        Some("userid_unknown") => drda::secchkcd::USERID_UNKNOWN,
521                        Some("userid_missing") => drda::secchkcd::USERID_MISSING,
522                        _ => drda::secchkcd::PASSWORD_INVALID,
523                    };
524                    info!("Db2 SECCHK decision=model_reject user={user_id} secchkcd=0x{code:02X}");
525                    let _ = self
526                        .status_tx
527                        .send("↩ Db2 login denied by model".to_string());
528                    secchkrm(corr, drda::svrcod::ERROR, code)
529                }
530                _ => {
531                    // The handler ran and produced neither verdict. Refuse: silence is not
532                    // consent for an authentication decision.
533                    warn!(
534                        "Db2 SECCHK decision=fail_closed_no_action user={user_id}: the handler \
535                         produced no db2_accept_connection; refusing"
536                    );
537                    secchkrm(corr, drda::svrcod::ERROR, drda::secchkcd::PASSWORD_INVALID)
538                }
539            },
540            Err(e) => {
541                // Fail closed, and never an accept. The SECCHKRM here is byte-identical to the
542                // no-action refusal above and differs from a model denial only in a code the
543                // model chose - DRDA has no field that could carry "netget could not reach a
544                // decision" - so the log is the only place the three can be told apart. That is
545                // the `radius` rule, and why these tags are stable strings rather than prose.
546                error!(
547                    "Db2 SECCHK decision=fail_closed_llm_error user={user_id}: LLM error: {:#}",
548                    e
549                );
550                let _ = self.status_tx.send(format!(
551                    "✗ Db2 auth backend error: {}",
552                    crate::utils::truncate_for_log(&e.to_string(), 200)
553                ));
554                secchkrm(corr, drda::svrcod::ERROR, drda::secchkcd::PASSWORD_INVALID)
555            }
556        }
557    }
558
559    /// ACCRDB -> ACCRDBRM: grant access to the RDB once authenticated.
560    ///
561    /// Without an accepted SECCHK the answer is RDBATHRM (not authorized to the RDB) at
562    /// severity ERROR. An ACCRDBRM is never the refusal, whatever its severity: IBM's CLI
563    /// driver keys on the code point and would report the connection open.
564    fn reply_accrdbrm(&self, corr: u16, body: &[u8]) -> Vec<u8> {
565        use drda::cp;
566        if !self.authenticated {
567            warn!("Db2 ACCRDB decision=fail_closed_not_authenticated: answering RDBATHRM");
568            let mut inner = drda::encode_scalar(cp::SVRCOD, &drda::svrcod::ERROR.to_be_bytes());
569            if let Some(rdbnam) = drda::find_param(body, cp::RDBNAM) {
570                inner.extend_from_slice(&drda::encode_scalar(cp::RDBNAM, &rdbnam));
571            }
572            let rm = drda::encode_object(cp::RDBATHRM, &inner);
573            return drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rm);
574        }
575        let severity = drda::svrcod::INFO;
576        let mut inner = Vec::new();
577        inner.extend_from_slice(&drda::encode_scalar(cp::SVRCOD, &severity.to_be_bytes()));
578        inner.extend_from_slice(&drda::encode_scalar_str(cp::PRDID, "SQL11055"));
579        inner.extend_from_slice(&drda::encode_scalar_str(cp::TYPDEFNAM, "QTDSQLASC"));
580        // The CCSIDs of the character data this server sends (SQLCA fields): UTF-8.
581        let mut ovr = drda::encode_scalar(cp::CCSIDSBC, &1208u16.to_be_bytes());
582        ovr.extend_from_slice(&drda::encode_scalar(cp::CCSIDMBC, &1208u16.to_be_bytes()));
583        inner.extend_from_slice(&drda::encode_scalar(cp::TYPDEFOVR, &ovr));
584        let rm = drda::encode_object(cp::ACCRDBRM, &inner);
585        drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rm)
586    }
587
588    /// Handle a statement: emit db2_query, then reply SQLCARD with the model's SQLCA.
589    async fn handle_query(&mut self, corr: u16, sql: String) -> Vec<u8> {
590        if !self.authenticated {
591            warn!(
592                "Db2 statement decision=fail_closed_not_authenticated: a statement arrived \
593                 before an accepted SECCHK; replying SQLCODE -30082"
594            );
595            let card = drda::sqlcard_error(-30082, "08001");
596            return drda::encode_dss(drda::DSSFMT_OBJDSS, false, corr, &card);
597        }
598
599        let event = Event::new(
600            &DB2_QUERY_EVENT,
601            serde_json::json!({
602                "sql_text": sql,
603                "statement_type": "execute_immediate",
604            }),
605        );
606
607        match call_llm(
608            &self.llm_client,
609            &self.app_state,
610            self.server_id,
611            Some(self.connection_id),
612            &event,
613            self.protocol.as_ref(),
614        )
615        .await
616        {
617            Ok(result) => {
618                let card = match first_custom(&result.protocol_results) {
619                    Some(("db2_query_ok", data)) => {
620                        let sqlcode = data.get("sqlcode").and_then(|v| v.as_i64()).unwrap_or(0);
621                        debug!("Db2 statement decision=model_answer sqlcode={sqlcode}");
622                        let rows = data.get("rows_affected").and_then(|v| v.as_i64());
623                        if let (0, Some(rows)) = (sqlcode, rows) {
624                            // The row count travels in SQLERRD3, which needs a full SQLCA.
625                            drda::sqlcard(0, "00000", rows.clamp(0, i32::MAX as i64) as i32)
626                        } else if sqlcode == 0 {
627                            drda::sqlcard_success()
628                        } else if sqlcode > 0 {
629                            // Positive SQLCODE (e.g. 100 = not found): warning SQLCA.
630                            drda::sqlcard_error(sqlcode as i32, "02000")
631                        } else {
632                            drda::sqlcard_error(sqlcode as i32, "42000")
633                        }
634                    }
635                    Some(("db2_query_error", data)) => {
636                        let sqlcode =
637                            data.get("sqlcode").and_then(|v| v.as_i64()).unwrap_or(-104) as i32;
638                        let sqlstate = data
639                            .get("sqlstate")
640                            .and_then(|v| v.as_str())
641                            .unwrap_or("42601");
642                        debug!(
643                            "Db2 statement decision=model_reject sqlcode={sqlcode} \
644                             sqlstate={sqlstate}"
645                        );
646                        drda::sqlcard_error(sqlcode, sqlstate)
647                    }
648                    _ => {
649                        // Fail closed: no usable answer -> error SQLCA, never a success. -901
650                        // is Db2's own "unexpected system error", which is what this is.
651                        warn!(
652                            "Db2 statement decision=fail_closed_no_action: the handler produced \
653                             no db2_query_ok or db2_query_error; returning SQLCODE -901"
654                        );
655                        drda::sqlcard_error(-901, "58004")
656                    }
657                };
658                drda::encode_dss(drda::DSSFMT_OBJDSS, false, corr, &card)
659            }
660            Err(e) => {
661                // The same SQLCA as the no-action path above, for the same reason as SECCHK:
662                // the SQLCA extended group that would carry message text is sent NULL, so the
663                // wire cannot carry the distinction and the log has to.
664                error!(
665                    "Db2 statement decision=fail_closed_llm_error: LLM error: {:#}",
666                    e
667                );
668                let _ = self.status_tx.send(format!(
669                    "✗ Db2 query backend error: {}",
670                    crate::utils::truncate_for_log(&e.to_string(), 200)
671                ));
672                let card = drda::sqlcard_error(-901, "58004");
673                drda::encode_dss(drda::DSSFMT_OBJDSS, false, corr, &card)
674            }
675        }
676    }
677}
678
679/// CMDNSPRM (command not supported) at severity ERROR, so the client gets a real reply instead
680/// of hanging. One per command: the command data chained to it is not answered separately.
681fn cmdnsprm(corr: u16, codepoint: u16) -> Vec<u8> {
682    use drda::cp;
683    warn!("Db2: unsupported DRDA command 0x{:04X}", codepoint);
684    let mut inner = drda::encode_scalar(cp::SVRCOD, &drda::svrcod::ERROR.to_be_bytes());
685    inner.extend_from_slice(&drda::encode_scalar(0x000C, &codepoint.to_be_bytes()));
686    let rm = drda::encode_object(cp::CMDNSPRM, &inner);
687    drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rm)
688}
689
690/// RDBCMM / RDBRLLBCK -> ENDUOWRM + SQLCARD. There is no storage, so there is nothing to commit
691/// or undo; the reply says the unit of work ended (`uowdsp` 1 committed, 2 rolled back), which
692/// is what a JDBC driver in autocommit sends after every statement and waits for.
693fn end_unit_of_work(corr: u16, uowdsp: u8) -> Vec<u8> {
694    use drda::cp;
695    let mut inner = drda::encode_scalar(cp::SVRCOD, &drda::svrcod::WARNING.to_be_bytes());
696    inner.extend_from_slice(&drda::encode_scalar(cp::UOWDSP, &[uowdsp]));
697    let rm = drda::encode_object(cp::ENDUOWRM, &inner);
698    let mut out = drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rm);
699    out.extend(drda::encode_dss(
700        drda::DSSFMT_OBJDSS,
701        false,
702        corr,
703        &drda::sqlcard_success(),
704    ));
705    out
706}
707
708/// Build a SECCHKRM (security-check reply message) with the given severity and
709/// security-check code.
710fn secchkrm(corr: u16, svrcod: u16, secchkcd: u8) -> Vec<u8> {
711    use drda::cp;
712    let mut inner = Vec::new();
713    inner.extend_from_slice(&drda::encode_scalar(cp::SVRCOD, &svrcod.to_be_bytes()));
714    inner.extend_from_slice(&drda::encode_scalar(cp::SECCHKCD, &[secchkcd]));
715    let rm = drda::encode_object(cp::SECCHKRM, &inner);
716    drda::encode_dss(drda::DSSFMT_RPYDSS, false, corr, &rm)
717}
718
719/// Find the first `Custom` action result, returning its name and data.
720fn first_custom(results: &[ActionResult]) -> Option<(&str, &serde_json::Value)> {
721    results.iter().find_map(|r| match r {
722        ActionResult::Custom { name, data } => Some((name.as_str(), data)),
723        _ => None,
724    })
725}