Skip to main content

netget/server/smb/
mod.rs

1//! SMB/CIFS server implementation
2//!
3//! Provides an SMB2 file server where the LLM controls the virtual filesystem. Sessions are
4//! guest or anonymous: the NTLMSSP exchange is walked so a real client finishes SESSION_SETUP,
5//! but no password is checked (see `auth.rs`).
6//!
7//! `wire.rs` owns every byte layout; this file owns the connection, the per-connection state
8//! and the model.
9
10pub mod actions;
11pub mod auth;
12pub mod wire;
13
14use anyhow::{Context, Result};
15use std::collections::{HashMap, VecDeque};
16use std::net::SocketAddr;
17use std::sync::Arc;
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::{TcpListener, TcpStream};
20use tokio::sync::{mpsc, Mutex};
21use tracing::{debug, error, info, trace, warn};
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::ollama_client::OllamaClient;
25use crate::protocol::Event;
26use crate::server::connection::ConnectionId;
27use crate::server::SmbProtocol;
28use crate::state::app_state::AppState;
29use crate::state::server::{
30    ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
31};
32use crate::state::ServerId;
33
34use crate::logging::emit::Log;
35use actions::SMB_OPERATION_EVENT;
36use wire::{command, status, FileMeta, RequestHeader, ResponseHeader, HEADER_LEN};
37
38/// How long to wait for a peer's first SMB2 message after it has connected.
39///
40/// SMB2 is client-speaks-first: NEGOTIATE is the first message and the server says nothing
41/// before it. `smbclient`, the Windows redirector and `mount -t cifs` all send it inside the
42/// connect path, so a peer that has connected and sent nothing has begun no session — which is
43/// the state an unauthenticated flood lives in, and the one this bound exists for.
44const FIRST_MESSAGE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
45
46/// How long to wait for a *further* message once a session exists.
47///
48/// Fifteen minutes, which is not a number invented here: it is Windows' `autodisconnect`
49/// default — the interval after which a server disconnects an idle SMB session — expressed in
50/// seconds. A mounted share with no I/O is genuinely idle for long stretches and must not be
51/// torn down for it, and copying the number every Windows client already expects is the one
52/// choice a real deployment cannot be surprised by.
53const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
54
55/// How long a peer may stall part-way through a message it has already announced.
56///
57/// Separate from the two above, and much shorter, because it is a different claim: the peer has
58/// said "this many bytes are coming" in the transport header and the server has already
59/// allocated for them. Nothing legitimate takes half a minute to finish delivering a message
60/// whose length has landed.
61const BODY_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
62
63/// The three read deadlines of one server, each a startup parameter defaulting to the constant
64/// above it (`first_byte_timeout_secs`, `idle_timeout_secs`, `body_timeout_secs`).
65///
66/// Configurable because the idle one is fifteen minutes, which no test can wait out, and a
67/// deadline nobody can test is a comment. The defaults are the numbers argued beside each
68/// constant.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Deadlines {
71    /// Silence allowed before any session has been admitted.
72    pub first_message: std::time::Duration,
73    /// Silence allowed between messages once a session has been admitted.
74    pub idle: std::time::Duration,
75    /// How long a peer may stall part-way through a message it has announced.
76    pub body: std::time::Duration,
77}
78
79impl Default for Deadlines {
80    fn default() -> Self {
81        Self {
82            first_message: FIRST_MESSAGE_READ_TIMEOUT,
83            idle: IDLE_BETWEEN_MESSAGES_TIMEOUT,
84            body: BODY_READ_TIMEOUT,
85        }
86    }
87}
88
89/// Concurrent connections this server admits.
90pub const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
91
92/// Sessions one connection may hold at once, those still mid-NTLMSSP and those the model has
93/// admitted counted together.
94///
95/// The first SESSION_SETUP of an NTLMSSP exchange opens a session **before anyone is asked**
96/// — nothing has been said about who is logging in yet — and a peer that never sends the
97/// second leg leaves it open until the connection closes. Without this bound a peer grows the
98/// session table by one entry per NEGOTIATE leg it sends, at no cost to itself. A real client
99/// holds one session per user on a connection and has one exchange in flight at a time; a
100/// multi-user redirector holds a handful. Sixteen leaves room for every legitimate shape. The
101/// refusal is `STATUS_INSUFFICIENT_RESOURCES`, the status Samba returns when its session table
102/// is full, logged `decision=fail_closed_session_cap`, and it is decided before the model.
103pub const MAX_SESSIONS_PER_CONNECTION: usize = 16;
104
105/// Tree connects one connection may hold at once.
106///
107/// TREE_CONNECT asks no one — the share is a name for the root of the tree the model invents —
108/// so nothing else stops one admitted session growing the tree table by a request at a time. A
109/// client connects `IPC$` and the shares it browses; 64 is far past that. Refused
110/// `STATUS_INSUFFICIENT_RESOURCES` (Samba's answer when its tree table is full), logged
111/// `decision=fail_closed_tree_cap`. TREE_DISCONNECT and LOGOFF return slots.
112pub const MAX_TREES_PER_CONNECTION: usize = 64;
113
114/// Open handles one connection may hold at once.
115///
116/// Each CREATE the model admits records a handle, including its path. The model stands in
117/// front of every one, but a `static` handler answering every CREATE does not, and a client
118/// that opens and never closes would grow the table until the connection ends. 1024 is far
119/// past what one smbclient, smbprotocol or Explorer session holds open. Refused
120/// `STATUS_TOO_MANY_OPENED_FILES` (what Samba answers past `max open files`) before the model
121/// is asked, logged `decision=fail_closed_open_file_cap`. CLOSE returns a slot.
122pub const MAX_OPEN_FILES_PER_CONNECTION: usize = 1024;
123
124/// A peer over [`MAX_CONNECTIONS`] gets a plain close, as a real SMB server does.
125///
126/// Every SMB2 response — an error response included — echoes the request's MessageId, TreeId
127/// and SessionId, and this peer has sent no request to echo. A response with those fields
128/// invented is a protocol violation rather than a diagnosis. Samba past `max smbd processes`
129/// and Windows past its connection limit both close without a message; the reason is in the
130/// log, tagged `decision=fail_closed_connection_cap`.
131const CONNECTION_CAP_REFUSAL: &[u8] = b"";
132
133/// The largest WRITE this server accepts, advertised as `MaxWriteSize` in the NEGOTIATE
134/// response. MS-SMB2 3.3.5.13: a WRITE longer than the negotiated `MaxWriteSize` MUST fail with
135/// `STATUS_INVALID_PARAMETER`, and it does, before the model is asked.
136pub const MAX_WRITE_SIZE: u32 = 1024 * 1024;
137
138/// The largest SMB2 message (one Direct TCP frame) this server reads, and its declared
139/// `max_inbound_bytes`.
140///
141/// The transport header announces the whole message's length before any of it is read, so this
142/// is the one number that bounds what a peer can make the server allocate: `MAX_WRITE_SIZE` of
143/// data plus 64 KiB for the header, the WRITE's fixed body and anything compounded with it. A
144/// frame announcing more is refused after reading only its 64-byte SMB2 header — enough to
145/// answer `STATUS_INVALID_PARAMETER` to the right MessageId — and the connection then closes,
146/// because the rest of the frame is unread and reading on would parse it as the next message.
147pub const MAX_MESSAGE_BYTES: usize = MAX_WRITE_SIZE as usize + 64 * 1024;
148
149/// Requests one compound frame may carry that the server will act on.
150///
151/// A frame of `MAX_MESSAGE_BYTES` has room for ~17 000 64-byte headers linked by
152/// `NextCommand`, and every one could be a CREATE or READ the model is asked about — one frame
153/// buying thousands of model calls. Real clients compound a handful (smbprotocol's stat is
154/// seven; the Windows and Linux redirectors stay under ten). Requests past this many are each
155/// answered `STATUS_INSUFFICIENT_RESOURCES` to their own MessageId without being acted on,
156/// logged `decision=fail_closed_compound_cap`, so the client can still correlate every reply.
157pub const MAX_COMPOUND_REQUESTS: usize = 32;
158
159/// The most response bytes one compound reply carries before further responses in it are
160/// replaced by `STATUS_INSUFFICIENT_RESOURCES`.
161///
162/// The Direct TCP header can announce at most 2^24 - 1 bytes, and a compound of READs can ask
163/// for more than that (32 x `MaxReadSize` is 32 MiB). Without this the reply could not be framed
164/// at all. The headroom is what the refusals themselves can cost: at most
165/// `MAX_MESSAGE_BYTES / 64` requests in a frame, each refused in 80 padded bytes, is ~1.4 MB,
166/// so 2 MiB below the frame limit keeps the whole chain frameable whatever follows. Logged
167/// `decision=fail_closed_response_too_large`.
168pub const MAX_RESPONSE_FRAME_BYTES: usize = wire::NBSS_MAX_LEN - 2 * 1024 * 1024;
169
170/// The largest READ this server answers, advertised as `MaxReadSize`. MS-SMB2 3.3.5.12: a READ
171/// asking for more MUST fail with `STATUS_INVALID_PARAMETER`, and it does, before the model is
172/// asked, logged `decision=fail_closed_read_too_large`.
173pub const MAX_READ_SIZE: u32 = 1024 * 1024;
174
175/// The largest output buffer a QUERY_INFO or QUERY_DIRECTORY may ask for, advertised as
176/// `MaxTransactSize`. MS-SMB2 3.3.5.18 and 3.3.5.20: an `OutputBufferLength` past it MUST fail
177/// with `STATUS_INVALID_PARAMETER`, before the model is asked.
178pub const MAX_TRANSACT_SIZE: u32 = 1024 * 1024;
179
180/// A fixed server GUID. Clients key cached connection state on it; one per process is enough.
181const SERVER_GUID: [u8; 16] = [
182    0x4e, 0x65, 0x74, 0x47, 0x65, 0x74, 0x53, 0x4d, 0x42, 0x32, 0x2d, 0x73, 0x72, 0x76, 0x01, 0x00,
183];
184
185/// `STATUS_NOT_A_DIRECTORY`: the client asked for a directory and the model said file.
186const STATUS_NOT_A_DIRECTORY: u32 = 0xC000_0103;
187/// `STATUS_FILE_IS_A_DIRECTORY`: the client asked for a non-directory and the model said dir.
188const STATUS_FILE_IS_A_DIRECTORY: u32 = 0xC000_00BA;
189/// `STATUS_INFO_LENGTH_MISMATCH`: the client's output buffer cannot hold the answer.
190const STATUS_INFO_LENGTH_MISMATCH: u32 = 0xC000_0004;
191
192// CREATE request fields (MS-SMB2 2.2.13).
193const FILE_CREATE: u32 = 0x0000_0002;
194const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
195const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040;
196const FILE_DELETE_ON_CLOSE: u32 = 0x0000_1000;
197
198// QUERY_DIRECTORY request flags (MS-SMB2 2.2.33).
199const RESTART_SCANS: u8 = 0x01;
200const RETURN_SINGLE_ENTRY: u8 = 0x02;
201const REOPEN: u8 = 0x10;
202
203/// The dialects this server speaks, best first.
204const DIALECT_SMB_2_1: u16 = 0x0210;
205const DIALECT_SMB_2_0_2: u16 = 0x0202;
206
207/// SMB server that provides LLM-controlled file system
208pub struct SmbServer;
209
210/// An SMB2 session. It exists from the first SESSION_SETUP of an NTLMSSP exchange, and becomes
211/// `authenticated` only when the model has admitted the user.
212#[derive(Debug, Clone)]
213struct SmbSession {
214    username: String,
215    authenticated: bool,
216}
217
218/// SMB2 tree connection state
219#[derive(Debug, Clone)]
220struct SmbTreeConnect {
221    session_id: u64,
222    share_name: String,
223    is_pipe: bool,
224}
225
226/// An open file or directory.
227#[derive(Debug, Clone)]
228struct SmbFileHandle {
229    path: String,
230    tree_id: u32,
231    meta: FileMeta,
232    /// Whether `meta.size` came from the model, so a later QUERY_INFO on the same handle need
233    /// not ask again.
234    size_known: bool,
235    /// An enumeration in progress on a directory handle: the entries not yet returned.
236    listing: Option<VecDeque<(String, FileMeta)>>,
237}
238
239/// Per-connection SMB state
240struct SmbConnectionState {
241    sessions: HashMap<u64, SmbSession>,
242    trees: HashMap<u32, SmbTreeConnect>,
243    files: HashMap<[u8; 16], SmbFileHandle>,
244    next_session_id: u64,
245    next_tree_id: u32,
246    next_file_index: u64,
247}
248
249impl SmbConnectionState {
250    fn new() -> Self {
251        Self {
252            sessions: HashMap::new(),
253            trees: HashMap::new(),
254            files: HashMap::new(),
255            next_session_id: 1,
256            next_tree_id: 1,
257            next_file_index: 1,
258        }
259    }
260
261    fn has_authenticated_session(&self) -> bool {
262        self.sessions.values().any(|s| s.authenticated)
263    }
264
265    fn allocate_session(&mut self, username: String, authenticated: bool) -> u64 {
266        let sid = self.next_session_id;
267        self.next_session_id += 1;
268        self.sessions.insert(
269            sid,
270            SmbSession {
271                username,
272                authenticated,
273            },
274        );
275        sid
276    }
277}
278
279/// Everything a command handler needs besides the request itself.
280struct Ctx<'a> {
281    llm_client: &'a OllamaClient,
282    app_state: &'a Arc<AppState>,
283    server_id: ServerId,
284    connection_id: ConnectionId,
285    protocol: &'a Arc<SmbProtocol>,
286    state: &'a Mutex<SmbConnectionState>,
287    status_tx: &'a mpsc::UnboundedSender<String>,
288    deadlines: Deadlines,
289}
290
291/// What earlier requests in the same compound chain established, for a request flagged
292/// `RELATED_OPERATIONS` (MS-SMB2 3.3.5.2.7.2).
293#[derive(Debug, Default, Clone, Copy)]
294struct Chain {
295    session_id: u64,
296    tree_id: u32,
297    file_id: Option<[u8; 16]>,
298}
299
300/// A FileId of all ones in a related request means "the handle the chain just opened".
301const RELATED_FILE_ID: [u8; 16] = [0xFF; 16];
302
303impl SmbServer {
304    /// Spawn SMB server with integrated LLM actions
305    pub async fn spawn_with_llm_actions(
306        listen_addr: SocketAddr,
307        llm_client: OllamaClient,
308        app_state: Arc<AppState>,
309        status_tx: mpsc::UnboundedSender<String>,
310        server_id: ServerId,
311        deadlines: Deadlines,
312    ) -> Result<SocketAddr> {
313        Log::new(Some(&status_tx)).info(format!(
314            "SMB server (LLM-controlled, guest-only) starting on {}",
315            listen_addr
316        ));
317
318        let protocol = Arc::new(SmbProtocol::new());
319
320        // Bind TCP listener
321        let listener = TcpListener::bind(listen_addr)
322            .await
323            .context("Failed to bind SMB TCP listener")?;
324
325        let actual_addr = listener.local_addr()?;
326
327        Log::new(Some(&status_tx)).info(format!("SMB server listening on {}", actual_addr));
328
329        // Spawn connection acceptor
330        let task_registrar = app_state.clone();
331        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
332        let accept_handle = tokio::spawn(async move {
333            info!("SMB server connection acceptor started");
334
335            loop {
336                trace!("SMB acceptor: waiting for connection");
337
338                match crate::server::accept_bounded::accept_bounded(
339                    &listener,
340                    &limiter,
341                    CONNECTION_CAP_REFUSAL,
342                    "SMB",
343                    Some(&status_tx),
344                )
345                .await
346                {
347                    Ok((stream, peer_addr, permit)) => {
348                        Log::new(Some(&status_tx))
349                            .info(format!("SMB connection accepted from {}", peer_addr));
350
351                        // Spawn per-connection handler
352                        let llm_client = llm_client.clone();
353                        let app_state = app_state.clone();
354                        let protocol = protocol.clone();
355                        let status_tx = status_tx.clone();
356
357                        // Tracked, not detached: stop_server must abort this task too.
358                        let task_owner = app_state.clone();
359                        task_owner
360                            .spawn_server_task(server_id, async move {
361                                // Held for the life of the connection, so the cap counts live
362                                // peers rather than accepts.
363                                let _permit = permit;
364                                if let Err(e) = Self::handle_connection(
365                                    stream,
366                                    peer_addr,
367                                    llm_client,
368                                    app_state,
369                                    server_id,
370                                    protocol,
371                                    status_tx.clone(),
372                                    deadlines,
373                                )
374                                .await
375                                {
376                                    Log::new(Some(&status_tx)).error(format!(
377                                        "SMB connection error from {}: {}",
378                                        peer_addr, e
379                                    ));
380                                }
381                            })
382                            .await;
383                    }
384                    Err(e) => {
385                        Log::new(Some(&status_tx)).error(format!("SMB accept error: {}", e));
386                    }
387                }
388            }
389        });
390
391        // Register the accept loop so stop_server can abort it and release the port.
392        task_registrar
393            .register_server_task(server_id, accept_handle)
394            .await;
395
396        Ok(actual_addr)
397    }
398
399    /// Handle a single SMB connection
400    #[allow(clippy::too_many_arguments)]
401    async fn handle_connection(
402        stream: TcpStream,
403        peer_addr: SocketAddr,
404        llm_client: OllamaClient,
405        app_state: Arc<AppState>,
406        server_id: ServerId,
407        protocol: Arc<SmbProtocol>,
408        status_tx: mpsc::UnboundedSender<String>,
409        deadlines: Deadlines,
410    ) -> Result<()> {
411        // Generate connection ID
412        let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
413
414        Log::new(Some(&status_tx)).info(format!(
415            "SMB connection {} from {}",
416            connection_id, peer_addr
417        ));
418
419        // Get local address for tracking
420        let local_addr = stream
421            .local_addr()
422            .unwrap_or_else(|_| "0.0.0.0:0".parse().unwrap());
423
424        // Track connection in app state
425        let now = crate::utils::clock::Instant::now();
426        let conn_state = ServerConnectionState {
427            id: connection_id,
428            remote_addr: peer_addr,
429            local_addr,
430            bytes_sent: 0,
431            bytes_received: 0,
432            packets_sent: 0,
433            packets_received: 0,
434            last_activity: now,
435            status: ConnectionStatus::Active,
436            status_changed_at: now,
437            protocol_info: ProtocolConnectionInfo::empty(),
438        };
439
440        app_state
441            .add_connection_to_server(server_id, conn_state)
442            .await;
443        let _ = status_tx.send("__UPDATE_UI__".to_string());
444
445        // Split the socket (never clone it): the read half stays with the session loop while
446        // the write half is shared, through an `Arc<Mutex<..>>`, with the dashboard's
447        // peer-command task. Both write through the same lock, so an injected message cannot
448        // interleave with a response half-way through an SMB2 frame.
449        let (reader, write_half) = tokio::io::split(stream);
450        let write_half = Arc::new(Mutex::new(write_half));
451        let mut reader = SmbReader::new(reader);
452
453        // Peer messaging: the dashboard's "[ message this peer ]" / "[ disconnect this peer ]"
454        // inject an action into THIS connection through the same executor the LLM path uses.
455        // Registered BEFORE the first read, because SMB2 is client-speaks-first and a manual
456        // `*` rule parks the very first NEGOTIATE for a human — the operator must be able to
457        // reach, or hang up, a connection that has not said anything yet.
458        let peer_rx = crate::server::peer_support::register_peer_channel(
459            &app_state,
460            server_id,
461            connection_id.as_u32(),
462        )
463        .await;
464        crate::server::peer_support::spawn_peer_command_task(
465            peer_rx,
466            protocol.clone(),
467            app_state.clone(),
468            server_id,
469            connection_id.as_u32(),
470            write_half.clone(),
471            status_tx.clone(),
472        );
473
474        let ctx_state = Mutex::new(SmbConnectionState::new());
475        let ctx = Ctx {
476            llm_client: &llm_client,
477            app_state: &app_state,
478            server_id,
479            connection_id,
480            protocol: &protocol,
481            state: &ctx_state,
482            status_tx: &status_tx,
483            deadlines,
484        };
485        Self::run_smb_session(&mut reader, &write_half, peer_addr, &ctx).await;
486
487        // Every exit path — EOF, an idle timeout, a read or write error, a refused frame —
488        // lands here. Dropping the handle also ends the peer command task, which releases its
489        // clone of the write half; the explicit shutdown makes the FIN immediate rather than
490        // waiting on it.
491        app_state
492            .remove_peer_handle(server_id, connection_id.as_u32())
493            .await;
494        let _ = write_half.lock().await.shutdown().await;
495
496        // Mark connection as closed
497        app_state
498            .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
499            .await;
500        let _ = status_tx.send("__UPDATE_UI__".to_string());
501
502        Log::new(Some(&status_tx)).info(format!("SMB connection {} closed", connection_id));
503
504        Ok(())
505    }
506
507    /// The read/dispatch/reply loop. Every exit is a `break`, so the caller's teardown always
508    /// runs; writes go through the shared `write_half` and are counted there.
509    ///
510    /// **Transport.** SMB2 over TCP is framed (MS-SMB2 2.1): each message, or compound chain of
511    /// messages, is preceded by a byte of zero and a 24-bit big-endian length. The SMB2 header
512    /// itself carries no length, so this frame is the only thing that says where one request
513    /// ends — every request is read whole before anything is decided about it, which is what
514    /// keeps the stream in step when a request is refused.
515    async fn run_smb_session<R, W>(
516        reader: &mut SmbReader<R>,
517        write_half: &Arc<Mutex<W>>,
518        peer_addr: SocketAddr,
519        ctx: &Ctx<'_>,
520    ) where
521        R: tokio::io::AsyncRead + Unpin,
522        W: tokio::io::AsyncWrite + Unpin,
523    {
524        loop {
525            // The deadline wraps this read and nothing else. The LLM round-trip that decides a
526            // login or answers a request, and a `manual` rule parking either for a human
527            // (`src/state/intercepts.rs`, 300s by default), all happen below once a whole
528            // message has been read — outside every deadline by construction. What is bounded
529            // is a peer holding the connection while sending nothing.
530            let header_timeout = if ctx.state.lock().await.has_authenticated_session() {
531                ctx.deadlines.idle
532            } else {
533                ctx.deadlines.first_message
534            };
535            let mut transport = [0u8; 4];
536            match tokio::time::timeout(header_timeout, reader.read_exact_counted(&mut transport))
537                .await
538            {
539                Err(_) => {
540                    Log::new(Some(ctx.status_tx)).info(format!(
541                        "SMB peer {} sent nothing for {}s; closing idle connection",
542                        peer_addr,
543                        header_timeout.as_secs()
544                    ));
545                    break;
546                }
547                Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
548                    Log::new(Some(ctx.status_tx))
549                        .info(format!("SMB client {} disconnected", peer_addr));
550                    break;
551                }
552                Ok(Err(e)) => {
553                    error!("SMB read error from {}: {}", peer_addr, e);
554                    break;
555                }
556                Ok(Ok(_)) => {}
557            }
558            flush_read_stats(reader, ctx.app_state, ctx.server_id, ctx.connection_id).await;
559
560            let (kind, len) = wire::parse_frame_header(transport);
561            match kind {
562                wire::NBSS_SESSION_MESSAGE => {}
563                wire::NBSS_KEEPALIVE => continue,
564                wire::NBSS_SESSION_REQUEST if len <= 256 => {
565                    // A client that dialled port 139 names the called and calling NetBIOS
566                    // names first. Any name is this server's; say yes and carry on.
567                    let mut names = vec![0u8; len];
568                    if read_body_exact(reader, &mut names, ctx.deadlines.body)
569                        .await
570                        .is_err()
571                    {
572                        break;
573                    }
574                    let positive = [wire::NBSS_POSITIVE_RESPONSE, 0, 0, 0];
575                    if write_counted(write_half, &positive, ctx).await.is_err() {
576                        break;
577                    }
578                    continue;
579                }
580                _ => {
581                    Log::new(Some(ctx.status_tx)).warn(format!(
582                        "SMB peer {} sent {:02x?}, which is not a Direct TCP session message \
583                         (MS-SMB2 2.1: 0x00 and a 24-bit length); closing",
584                        peer_addr, transport
585                    ));
586                    break;
587                }
588            }
589
590            if len < HEADER_LEN {
591                Log::new(Some(ctx.status_tx)).warn(format!(
592                    "SMB peer {} framed a {}-byte message, shorter than an SMB2 header; closing",
593                    peer_addr, len
594                ));
595                break;
596            }
597
598            if len > MAX_MESSAGE_BYTES {
599                // Refused before the frame is allocated. Only the SMB2 header is read, so the
600                // refusal can name the request it refuses; the rest is unread, so the stream
601                // is out of step and the connection closes after the reply.
602                let mut header = [0u8; HEADER_LEN];
603                if read_body_exact(reader, &mut header, ctx.deadlines.body)
604                    .await
605                    .is_ok()
606                {
607                    if let Some(req) = RequestHeader::parse(&header) {
608                        Log::new(Some(ctx.status_tx)).warn(format!(
609                            "SMB2 command 0x{:04x} in a {} byte frame refused \
610                             (decision=fail_closed_message_too_large, limit {}); replying \
611                             STATUS_INVALID_PARAMETER and closing",
612                            req.command, len, MAX_MESSAGE_BYTES
613                        ));
614                        let reply = wire::error_response(&ResponseHeader::for_request(
615                            &req,
616                            status::INVALID_PARAMETER,
617                        ));
618                        let _ = write_counted(write_half, &wire::frame(&reply), ctx).await;
619                    }
620                }
621                flush_read_stats(reader, ctx.app_state, ctx.server_id, ctx.connection_id).await;
622                break;
623            }
624
625            let mut message = vec![0u8; len];
626            if let Err(e) = read_body_exact(reader, &mut message, ctx.deadlines.body).await {
627                debug!(
628                    "SMB peer {} did not deliver its announced frame: {}",
629                    peer_addr, e
630                );
631                break;
632            }
633            flush_read_stats(reader, ctx.app_state, ctx.server_id, ctx.connection_id).await;
634
635            let responses = match Self::process_frame(&message, peer_addr, ctx).await {
636                Some(responses) => responses,
637                None => break,
638            };
639            if responses.is_empty() {
640                continue;
641            }
642            let framed = wire::frame(&wire::chain(responses));
643            match write_counted(write_half, &framed, ctx).await {
644                Ok(()) => trace!(
645                    "SMB2 response sent to {}, {} bytes",
646                    peer_addr,
647                    framed.len()
648                ),
649                Err(e) => {
650                    error!("Failed to send SMB2 response to {}: {}", peer_addr, e);
651                    break;
652                }
653            }
654        }
655    }
656
657    /// Answer every request in one frame. A frame carries one request, or a compound chain
658    /// linked by `NextCommand` (MS-SMB2 3.3.5.2.7); the responses are chained the same way.
659    ///
660    /// `None` means the frame is not SMB2 at all and the connection should close.
661    async fn process_frame(
662        frame: &[u8],
663        peer_addr: SocketAddr,
664        ctx: &Ctx<'_>,
665    ) -> Option<Vec<Vec<u8>>> {
666        let mut responses = Vec::new();
667        let mut chain = Chain::default();
668        let mut offset = 0usize;
669        let mut requests = 0usize;
670        let mut response_bytes = 0usize;
671
672        loop {
673            let rest = &frame[offset..];
674            let located = match wire::next_in_chain(rest) {
675                Ok(located) => located,
676                Err(wire::ChainError::NotSmb2) if offset == 0 => {
677                    let what = if rest.starts_with(b"\xFFSMB") {
678                        "an SMB1 message; this server speaks SMB2 only"
679                    } else {
680                        "not an SMB2 message"
681                    };
682                    Log::new(Some(ctx.status_tx)).warn(format!(
683                        "Invalid SMB2 signature from {} ({})",
684                        peer_addr, what
685                    ));
686                    return None;
687                }
688                Err(wire::ChainError::NotSmb2) => {
689                    warn!(
690                        "SMB2 compound chain from {} ends in a malformed request",
691                        peer_addr
692                    );
693                    break;
694                }
695                Err(wire::ChainError::BadNextCommand(req)) => {
696                    warn!(
697                        "SMB2 NextCommand {} from {} does not point at a request; refusing",
698                        req.next_command, peer_addr
699                    );
700                    responses.push(wire::error_response(&ResponseHeader::for_request(
701                        &req,
702                        status::INVALID_PARAMETER,
703                    )));
704                    break;
705                }
706            };
707            let mut req = located.header;
708            let message = located.message;
709
710            if req.is_related() && offset > 0 {
711                req.session_id = chain.session_id;
712                req.tree_id = chain.tree_id;
713            }
714            chain.session_id = req.session_id;
715            chain.tree_id = req.tree_id;
716
717            debug!("SMB2 command 0x{:04x} from {}", req.command, peer_addr);
718            requests += 1;
719            let response = if requests > MAX_COMPOUND_REQUESTS {
720                if requests == MAX_COMPOUND_REQUESTS + 1 {
721                    Log::new(Some(ctx.status_tx)).warn(format!(
722                        "SMB2 compound from {} carries more than {} requests \
723                         (decision=fail_closed_compound_cap); answering the rest \
724                         STATUS_INSUFFICIENT_RESOURCES without acting on them",
725                        peer_addr, MAX_COMPOUND_REQUESTS
726                    ));
727                }
728                Some(wire::error_response(&ResponseHeader::for_request(
729                    &req,
730                    status::INSUFFICIENT_RESOURCES,
731                )))
732            } else {
733                match Self::handle_request(&req, message, &mut chain, ctx).await {
734                    Ok(response) => response,
735                    Err(e) => {
736                        error!("SMB2 command 0x{:04x} failed: {}", req.command, e);
737                        Some(wire::error_response(&ResponseHeader::for_request(
738                            &req,
739                            status::INTERNAL_ERROR,
740                        )))
741                    }
742                }
743            };
744            if let Some(mut response) = response {
745                // Each response but the last is padded to 8 bytes in the chain.
746                if response_bytes + response.len().div_ceil(8) * 8 > MAX_RESPONSE_FRAME_BYTES {
747                    Log::new(Some(ctx.status_tx)).warn(format!(
748                        "SMB2 command 0x{:04x} from {}: its {} byte response would take the \
749                         compound reply past {} bytes (decision=fail_closed_response_too_large); \
750                         replying STATUS_INSUFFICIENT_RESOURCES",
751                        req.command,
752                        peer_addr,
753                        response.len(),
754                        MAX_RESPONSE_FRAME_BYTES
755                    ));
756                    response = wire::error_response(&ResponseHeader::for_request(
757                        &req,
758                        status::INSUFFICIENT_RESOURCES,
759                    ));
760                }
761                response_bytes += response.len().div_ceil(8) * 8;
762                responses.push(response);
763            }
764
765            match located.next {
766                Some(next) => offset += next,
767                None => break,
768            }
769        }
770        Some(responses)
771    }
772
773    /// Answer one SMB2 request. `message` is the request's header and body, and nothing past
774    /// it. `Ok(None)` is the one command that gets no response (CANCEL).
775    async fn handle_request(
776        req: &RequestHeader,
777        message: &[u8],
778        chain: &mut Chain,
779        ctx: &Ctx<'_>,
780    ) -> Result<Option<Vec<u8>>> {
781        let body = &message[HEADER_LEN..];
782        let error = |code: u32| -> Result<Option<Vec<u8>>> {
783            Ok(Some(wire::error_response(&ResponseHeader::for_request(
784                req, code,
785            ))))
786        };
787
788        // Nothing but the handshake may be served on a session the model has not admitted.
789        //
790        // `SmbConnectionState.sessions` was once written by the successful auth path and read
791        // by nothing but a log line, so the authentication decision governed exactly one
792        // response: a peer could open a socket and send CREATE or READ straight away, and a
793        // peer whose login the model had just *denied* could send CREATE on the same
794        // connection and be served. The SessionId in the header has to name a session the
795        // model admitted, or asking the model was decorative.
796        let exempt = matches!(
797            req.command,
798            command::NEGOTIATE | command::SESSION_SETUP | command::ECHO | command::CANCEL
799        );
800        if !exempt {
801            let admitted = ctx
802                .state
803                .lock()
804                .await
805                .sessions
806                .get(&req.session_id)
807                .is_some_and(|s| s.authenticated);
808            if !admitted {
809                Log::new(Some(ctx.status_tx)).warn(format!(
810                    "SMB2 command 0x{:04x} refused (decision=fail_closed_no_session): session \
811                     {} is not an authenticated session on this connection; replying \
812                     STATUS_USER_SESSION_DELETED",
813                    req.command, req.session_id
814                ));
815                return error(status::USER_SESSION_DELETED);
816            }
817        }
818
819        // Commands addressed to a share need a tree the same session connected.
820        let needs_tree = !matches!(
821            req.command,
822            command::NEGOTIATE
823                | command::SESSION_SETUP
824                | command::LOGOFF
825                | command::TREE_CONNECT
826                | command::ECHO
827                | command::CANCEL
828        );
829        if needs_tree {
830            let connected = ctx
831                .state
832                .lock()
833                .await
834                .trees
835                .get(&req.tree_id)
836                .is_some_and(|t| t.session_id == req.session_id);
837            if !connected {
838                Log::new(Some(ctx.status_tx)).warn(format!(
839                    "SMB2 command 0x{:04x} refused: tree {} is not connected on session {}; \
840                     replying STATUS_NETWORK_NAME_DELETED",
841                    req.command, req.tree_id, req.session_id
842                ));
843                return error(status::NETWORK_NAME_DELETED);
844            }
845        }
846
847        match req.command {
848            command::NEGOTIATE => Self::negotiate(req, body, ctx),
849            command::SESSION_SETUP => Self::session_setup(req, message, ctx).await,
850            command::LOGOFF => {
851                let mut s = ctx.state.lock().await;
852                if let Some(session) = s.sessions.remove(&req.session_id) {
853                    debug!(
854                        "SMB2 LOGOFF: session {} ({:?})",
855                        req.session_id, session.username
856                    );
857                }
858                s.trees.retain(|_, t| t.session_id != req.session_id);
859                // Handles on the trees just dropped can never be reached again.
860                let trees: std::collections::HashSet<u32> = s.trees.keys().copied().collect();
861                s.files.retain(|_, f| trees.contains(&f.tree_id));
862                Ok(Some(wire::empty_response(&ResponseHeader::for_request(
863                    req,
864                    status::SUCCESS,
865                ))))
866            }
867            command::TREE_CONNECT => Self::tree_connect(req, message, ctx).await,
868            command::TREE_DISCONNECT => {
869                let mut s = ctx.state.lock().await;
870                s.trees.remove(&req.tree_id);
871                s.files.retain(|_, f| f.tree_id != req.tree_id);
872                Ok(Some(wire::empty_response(&ResponseHeader::for_request(
873                    req,
874                    status::SUCCESS,
875                ))))
876            }
877            command::CREATE => Self::create(req, message, chain, ctx).await,
878            command::CLOSE => Self::close(req, body, chain, ctx).await,
879            command::FLUSH => {
880                let Some(file_id) = wire::parse_flush(body) else {
881                    return error(status::INVALID_PARAMETER);
882                };
883                if Self::handle(ctx, req, file_id, chain).await.is_none() {
884                    return error(status::FILE_CLOSED);
885                }
886                Ok(Some(wire::empty_response(&ResponseHeader::for_request(
887                    req,
888                    status::SUCCESS,
889                ))))
890            }
891            command::READ => Self::read(req, body, chain, ctx).await,
892            command::WRITE => Self::write(req, message, chain, ctx).await,
893            command::QUERY_INFO => Self::query_info(req, body, chain, ctx).await,
894            command::QUERY_DIRECTORY => Self::query_directory(req, message, chain, ctx).await,
895            command::ECHO => Ok(Some(wire::empty_response(&ResponseHeader::for_request(
896                req,
897                status::SUCCESS,
898            )))),
899            // MS-SMB2 3.3.5.16: a CANCEL is never answered.
900            command::CANCEL => Ok(None),
901            command::IOCTL => {
902                // No FSCTL is implemented: no DFS referrals, no pipe transceive, and
903                // VALIDATE_NEGOTIATE_INFO belongs to SMB 3.x, which this server does not offer.
904                debug!("SMB2 IOCTL refused: no FSCTL is implemented");
905                error(status::INVALID_DEVICE_REQUEST)
906            }
907            other => {
908                Log::new(Some(ctx.status_tx)).warn(format!(
909                    "Unsupported SMB2 command 0x{:04x}; replying STATUS_NOT_SUPPORTED",
910                    other
911                ));
912                error(status::NOT_SUPPORTED)
913            }
914        }
915    }
916
917    /// NEGOTIATE (MS-SMB2 3.3.5.4). Picks SMB 2.1 if offered, else 2.0.2, and offers SPNEGO
918    /// with NTLMSSP as the only mechanism.
919    fn negotiate(req: &RequestHeader, body: &[u8], ctx: &Ctx<'_>) -> Result<Option<Vec<u8>>> {
920        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
921        let Some(offered) = wire::parse_negotiate(body) else {
922            return Ok(Some(wire::error_response(
923                &hdr.with_status(status::INVALID_PARAMETER),
924            )));
925        };
926        let Some(dialect) = [DIALECT_SMB_2_1, DIALECT_SMB_2_0_2]
927            .into_iter()
928            .find(|d| offered.contains(d))
929        else {
930            Log::new(Some(ctx.status_tx)).warn(format!(
931                "SMB2 NEGOTIATE offered no dialect this server speaks ({:04x?}); it speaks \
932                 0x0210 and 0x0202",
933                offered
934            ));
935            return Ok(Some(wire::error_response(
936                &hdr.with_status(status::NOT_SUPPORTED),
937            )));
938        };
939        Log::new(Some(ctx.status_tx)).debug(format!(
940            "SMB2 NEGOTIATE: choosing dialect 0x{:04x}",
941            dialect
942        ));
943
944        let params = wire::NegotiateParams {
945            dialect,
946            server_guid: SERVER_GUID,
947            // No DFS, no leasing, no multi-credit: none is implemented, and each one is a
948            // promise a client acts on.
949            capabilities: 0,
950            max_transact_size: MAX_TRANSACT_SIZE,
951            max_read_size: MAX_READ_SIZE,
952            // The bound the WRITE arm enforces, so a client never sends a write it will refuse.
953            max_write_size: MAX_WRITE_SIZE,
954            system_time: filetime_now(),
955            security_blob: auth::negotiate_blob(),
956        };
957        Ok(Some(wire::negotiate_response(&hdr, &params)))
958    }
959
960    /// SESSION_SETUP (MS-SMB2 3.3.5.5).
961    ///
962    /// A real client sends two: an NTLMSSP NEGOTIATE, answered with a CHALLENGE and
963    /// `STATUS_MORE_PROCESSING_REQUIRED` without asking anyone, then an AUTHENTICATE naming the
964    /// user, which is what the model decides on. A SESSION_SETUP with an empty security buffer
965    /// is a one-step guest login, decided the same way.
966    async fn session_setup(
967        req: &RequestHeader,
968        message: &[u8],
969        ctx: &Ctx<'_>,
970    ) -> Result<Option<Vec<u8>>> {
971        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
972        let Some(blob) = wire::parse_session_setup(message) else {
973            return Ok(Some(wire::error_response(
974                &hdr.with_status(status::INVALID_PARAMETER),
975            )));
976        };
977
978        if blob.is_empty() {
979            // A one-step login allocates a session if the model admits it, so the cap is
980            // checked before the model is asked: a refusal it would have to make anyway must
981            // not cost a model call.
982            if let Some(refusal) = Self::session_cap_refusal(req, ctx).await {
983                return Ok(Some(refusal));
984            }
985            return Self::decide_login(
986                req,
987                None,
988                "guest",
989                "",
990                "guest",
991                false,
992                auth::Wrapping::Raw,
993                ctx,
994            )
995            .await;
996        }
997
998        let Some((token, wrapping)) = auth::find_ntlmssp(blob) else {
999            Log::new(Some(ctx.status_tx)).warn(
1000                "SMB2 SESSION_SETUP refused (decision=fail_closed_unsupported_mechanism): the \
1001                 security buffer carries no NTLMSSP token, and NTLMSSP is the only mechanism \
1002                 this server offers; replying STATUS_LOGON_FAILURE",
1003            );
1004            return Ok(Some(wire::error_response(
1005                &hdr.with_status(status::LOGON_FAILURE),
1006            )));
1007        };
1008
1009        match auth::message_type(token) {
1010            Some(auth::NTLMSSP_NEGOTIATE) => {
1011                // A NEGOTIATE leg naming a session whose exchange is still in progress restarts
1012                // that exchange (MS-SMB2 3.3.5.5) rather than opening another; anything else
1013                // opens a new session, which is where the per-connection cap applies.
1014                let restarted = {
1015                    let s = ctx.state.lock().await;
1016                    s.sessions
1017                        .get(&req.session_id)
1018                        .is_some_and(|s| !s.authenticated)
1019                        .then_some(req.session_id)
1020                };
1021                let session_id = match restarted {
1022                    Some(sid) => sid,
1023                    None => {
1024                        if let Some(refusal) = Self::session_cap_refusal(req, ctx).await {
1025                            return Ok(Some(refusal));
1026                        }
1027                        ctx.state
1028                            .lock()
1029                            .await
1030                            .allocate_session(String::new(), false)
1031                    }
1032                };
1033                let challenge = auth::challenge(
1034                    auth::negotiate_flags(token),
1035                    server_challenge(),
1036                    filetime_now(),
1037                );
1038                debug!(
1039                    "SMB2 SESSION_SETUP: NTLMSSP NEGOTIATE on new session {}; sending CHALLENGE",
1040                    session_id
1041                );
1042                let hdr = hdr
1043                    .with_status(status::MORE_PROCESSING_REQUIRED)
1044                    .with_session_id(session_id);
1045                Ok(Some(wire::session_setup_response(
1046                    &hdr,
1047                    0,
1048                    &auth::wrap_challenge(&challenge, wrapping),
1049                )))
1050            }
1051            Some(auth::NTLMSSP_AUTHENTICATE) => {
1052                let pending = ctx
1053                    .state
1054                    .lock()
1055                    .await
1056                    .sessions
1057                    .get(&req.session_id)
1058                    .is_some_and(|s| !s.authenticated);
1059                if !pending {
1060                    Log::new(Some(ctx.status_tx)).warn(format!(
1061                        "SMB2 SESSION_SETUP: NTLMSSP AUTHENTICATE for session {}, which has no \
1062                         exchange in progress; replying STATUS_USER_SESSION_DELETED",
1063                        req.session_id
1064                    ));
1065                    return Ok(Some(wire::error_response(
1066                        &hdr.with_status(status::USER_SESSION_DELETED),
1067                    )));
1068                }
1069                let Some(who) = auth::parse_authenticate(token) else {
1070                    ctx.state.lock().await.sessions.remove(&req.session_id);
1071                    return Ok(Some(wire::error_response(
1072                        &hdr.with_status(status::INVALID_PARAMETER),
1073                    )));
1074                };
1075                let auth_type = if who.anonymous { "anonymous" } else { "ntlm" };
1076                Self::decide_login(
1077                    req,
1078                    Some(req.session_id),
1079                    &who.user,
1080                    &who.domain,
1081                    auth_type,
1082                    who.anonymous,
1083                    wrapping,
1084                    ctx,
1085                )
1086                .await
1087            }
1088            _ => Ok(Some(wire::error_response(
1089                &hdr.with_status(status::INVALID_PARAMETER),
1090            ))),
1091        }
1092    }
1093
1094    /// Ask the model whether `username` may log in, and answer the SESSION_SETUP accordingly.
1095    /// `pending` is the session an NTLMSSP exchange already allocated; `None` allocates one.
1096    #[allow(clippy::too_many_arguments)]
1097    async fn decide_login(
1098        req: &RequestHeader,
1099        pending: Option<u64>,
1100        username: &str,
1101        domain: &str,
1102        auth_type: &str,
1103        anonymous: bool,
1104        wrapping: auth::Wrapping,
1105        ctx: &Ctx<'_>,
1106    ) -> Result<Option<Vec<u8>>> {
1107        Log::new(Some(ctx.status_tx)).info(format!(
1108            "SMB2 SESSION_SETUP for user: {:?} ({})",
1109            username, auth_type
1110        ));
1111
1112        let mut params = serde_json::json!({
1113            "username": username,
1114            "auth_type": auth_type,
1115        });
1116        if !domain.is_empty() {
1117            params["domain"] = serde_json::json!(domain);
1118        }
1119        if auth_type == "ntlm" {
1120            // Said to the model in as many words: nothing checked the password.
1121            params["password_verified"] = serde_json::json!(false);
1122        }
1123
1124        let actions = match Self::consult_llm(ctx, "session_setup", params).await {
1125            Ok(actions) => actions,
1126            Err(e) => {
1127                Log::new(Some(ctx.status_tx)).warn(format!(
1128                    "LLM error during SMB authentication for user {:?} \
1129                     (decision=fail_closed_llm_error) - denying auth: {}",
1130                    username, e
1131                ));
1132                return Self::deny_login(req, pending, ctx).await;
1133            }
1134        };
1135
1136        // `smb_auth_success` only: the model's affirmative answer is the one way in.
1137        let auth_allowed = actions
1138            .iter()
1139            .any(|a| a.get("type").and_then(|t| t.as_str()) == Some("smb_auth_success"));
1140
1141        if !auth_allowed {
1142            // Kept apart in the log because the wire cannot carry the difference: both answers
1143            // refuse the login, but only one of them is a decision.
1144            let decision = if actions.is_empty() {
1145                "fail_closed_no_action"
1146            } else {
1147                "model_reject"
1148            };
1149            Log::new(Some(ctx.status_tx)).warn(format!(
1150                "SMB authentication denied for user {:?} (decision={}); replying \
1151                 STATUS_ACCESS_DENIED",
1152                username, decision
1153            ));
1154            return Self::deny_login(req, pending, ctx).await;
1155        }
1156
1157        let session_id = {
1158            let mut s = ctx.state.lock().await;
1159            match pending {
1160                Some(sid) => {
1161                    if let Some(session) = s.sessions.get_mut(&sid) {
1162                        session.username = username.to_string();
1163                        session.authenticated = true;
1164                    }
1165                    sid
1166                }
1167                None => s.allocate_session(username.to_string(), true),
1168            }
1169        };
1170        Log::new(Some(ctx.status_tx)).info(format!(
1171            "SMB authentication successful for user: {:?} (session {})",
1172            username, session_id
1173        ));
1174        let _ = ctx.status_tx.send("__UPDATE_UI__".to_string());
1175
1176        // Nothing verified the password, so no session is anything but a guest (or, for an
1177        // anonymous login, a null) session — which is also what tells the client there is no
1178        // key to sign with.
1179        let flags = if anonymous {
1180            wire::SESSION_FLAG_IS_NULL
1181        } else {
1182            wire::SESSION_FLAG_IS_GUEST
1183        };
1184        let hdr = ResponseHeader::for_request(req, status::SUCCESS).with_session_id(session_id);
1185        Ok(Some(wire::session_setup_response(
1186            &hdr,
1187            flags,
1188            &auth::accept_completed(wrapping),
1189        )))
1190    }
1191
1192    /// Refuse a login: forget the session the exchange allocated and answer ACCESS_DENIED.
1193    async fn deny_login(
1194        req: &RequestHeader,
1195        pending: Option<u64>,
1196        ctx: &Ctx<'_>,
1197    ) -> Result<Option<Vec<u8>>> {
1198        if let Some(sid) = pending {
1199            ctx.state.lock().await.sessions.remove(&sid);
1200        }
1201        let hdr = ResponseHeader::for_request(req, status::ACCESS_DENIED);
1202        Ok(Some(wire::session_setup_response(&hdr, 0, &[])))
1203    }
1204
1205    /// The refusal for a SESSION_SETUP that would open a session past
1206    /// [`MAX_SESSIONS_PER_CONNECTION`], or `None` if there is room.
1207    async fn session_cap_refusal(req: &RequestHeader, ctx: &Ctx<'_>) -> Option<Vec<u8>> {
1208        let (held, pending) = {
1209            let s = ctx.state.lock().await;
1210            let pending = s.sessions.values().filter(|s| !s.authenticated).count();
1211            (s.sessions.len(), pending)
1212        };
1213        if held < MAX_SESSIONS_PER_CONNECTION {
1214            return None;
1215        }
1216        Log::new(Some(ctx.status_tx)).warn(format!(
1217            "SMB2 SESSION_SETUP refused (decision=fail_closed_session_cap): this connection \
1218             already holds {} sessions ({} still mid-exchange), the most one connection may \
1219             hold is {}; replying STATUS_INSUFFICIENT_RESOURCES",
1220            held, pending, MAX_SESSIONS_PER_CONNECTION
1221        ));
1222        Some(wire::error_response(&ResponseHeader::for_request(
1223            req,
1224            status::INSUFFICIENT_RESOURCES,
1225        )))
1226    }
1227
1228    /// TREE_CONNECT (MS-SMB2 3.3.5.7). Every share name is accepted without asking the model:
1229    /// the share is only a name for the root of the tree the model invents, and admission was
1230    /// decided at SESSION_SETUP. `IPC$` connects as a pipe share, and every operation on it is
1231    /// refused, because no named pipe is implemented.
1232    async fn tree_connect(
1233        req: &RequestHeader,
1234        message: &[u8],
1235        ctx: &Ctx<'_>,
1236    ) -> Result<Option<Vec<u8>>> {
1237        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1238        let Some(path) = wire::parse_tree_connect(message) else {
1239            return Ok(Some(wire::error_response(
1240                &hdr.with_status(status::INVALID_PARAMETER),
1241            )));
1242        };
1243        let share = path.rsplit('\\').next().unwrap_or("").to_string();
1244        let is_pipe = share.eq_ignore_ascii_case("IPC$");
1245
1246        let tree_id = {
1247            let mut s = ctx.state.lock().await;
1248            // No model call stands in front of a TREE_CONNECT, so nothing but this bound stops
1249            // one admitted session from growing the table by a tree per request.
1250            if s.trees.len() >= MAX_TREES_PER_CONNECTION {
1251                let held = s.trees.len();
1252                drop(s);
1253                Log::new(Some(ctx.status_tx)).warn(format!(
1254                    "SMB2 TREE_CONNECT {} refused (decision=fail_closed_tree_cap): this \
1255                     connection already holds {} tree connects, the most one connection may \
1256                     hold is {}; replying STATUS_INSUFFICIENT_RESOURCES",
1257                    path, held, MAX_TREES_PER_CONNECTION
1258                ));
1259                return Ok(Some(wire::error_response(
1260                    &hdr.with_status(status::INSUFFICIENT_RESOURCES),
1261                )));
1262            }
1263            let tid = s.next_tree_id;
1264            s.next_tree_id += 1;
1265            s.trees.insert(
1266                tid,
1267                SmbTreeConnect {
1268                    session_id: req.session_id,
1269                    share_name: share.clone(),
1270                    is_pipe,
1271                },
1272            );
1273            tid
1274        };
1275        Log::new(Some(ctx.status_tx)).info(format!(
1276            "SMB2 TREE_CONNECT {} -> tree {} ({})",
1277            path,
1278            tree_id,
1279            if is_pipe { "pipe" } else { "disk" }
1280        ));
1281        let share_type = if is_pipe {
1282            wire::SHARE_TYPE_PIPE
1283        } else {
1284            wire::SHARE_TYPE_DISK
1285        };
1286        Ok(Some(wire::tree_connect_response(
1287            &hdr.with_tree_id(tree_id),
1288            share_type,
1289            0x001F_01FF,
1290        )))
1291    }
1292
1293    /// Resolve a FileId from a request body, following a related compound's "the handle just
1294    /// opened". Returns the FileId and a copy of the handle.
1295    async fn handle(
1296        ctx: &Ctx<'_>,
1297        req: &RequestHeader,
1298        mut file_id: [u8; 16],
1299        chain: &Chain,
1300    ) -> Option<([u8; 16], SmbFileHandle)> {
1301        if req.is_related() && file_id == RELATED_FILE_ID {
1302            file_id = chain.file_id?;
1303        }
1304        let s = ctx.state.lock().await;
1305        let handle = s.files.get(&file_id)?;
1306        (handle.tree_id == req.tree_id).then(|| (file_id, handle.clone()))
1307    }
1308
1309    /// CREATE (MS-SMB2 3.3.5.9): the model decides whether the path opens, and as what.
1310    async fn create(
1311        req: &RequestHeader,
1312        message: &[u8],
1313        chain: &mut Chain,
1314        ctx: &Ctx<'_>,
1315    ) -> Result<Option<Vec<u8>>> {
1316        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1317        let Some(wire::CreateRequest {
1318            disposition,
1319            options,
1320            path,
1321        }) = wire::parse_create(message)
1322        else {
1323            return Ok(Some(wire::error_response(
1324                &hdr.with_status(status::INVALID_PARAMETER),
1325            )));
1326        };
1327        let (is_pipe, open) = {
1328            let s = ctx.state.lock().await;
1329            let is_pipe = s.trees.get(&req.tree_id).is_some_and(|t| t.is_pipe);
1330            (is_pipe, s.files.len())
1331        };
1332        if is_pipe {
1333            debug!("SMB2 CREATE on a pipe share refused: no named pipe is implemented");
1334            return Ok(Some(wire::error_response(
1335                &hdr.with_status(status::NOT_SUPPORTED),
1336            )));
1337        }
1338        // Checked before the model is asked, so a refusal the server would have to make anyway
1339        // costs no model call — and a static handler answering every CREATE cannot grow the
1340        // handle table without limit.
1341        if open >= MAX_OPEN_FILES_PER_CONNECTION {
1342            Log::new(Some(ctx.status_tx)).warn(format!(
1343                "SMB2 CREATE {} refused (decision=fail_closed_open_file_cap): this connection \
1344                 already holds {} open handles, the most one connection may hold is {}; \
1345                 replying STATUS_TOO_MANY_OPENED_FILES",
1346                path, open, MAX_OPEN_FILES_PER_CONNECTION
1347            ));
1348            return Ok(Some(wire::error_response(
1349                &hdr.with_status(status::TOO_MANY_OPENED_FILES),
1350            )));
1351        }
1352
1353        Log::new(Some(ctx.status_tx)).info(format!("SMB2 CREATE request for: {}", path));
1354
1355        let mut params = serde_json::json!({
1356            "path": path,
1357            "disposition": disposition_name(disposition),
1358            "directory_requested": options & FILE_DIRECTORY_FILE != 0,
1359        });
1360        // SMB2 has no DELETE command: a client deletes by opening with FILE_DELETE_ON_CLOSE and
1361        // closing (smbclient's `rm` and `rmdir`), so an open carrying it is a delete request and
1362        // the model has to be told so. Admitting the open admits the delete; CLOSE then answers
1363        // success without asking again.
1364        if options & FILE_DELETE_ON_CLOSE != 0 {
1365            params["delete_on_close"] = serde_json::json!(true);
1366        }
1367        let actions = match Self::consult_llm(ctx, "create", params).await {
1368            Ok(actions) => actions,
1369            Err(e) => {
1370                return Ok(Some(Self::llm_failure_response(
1371                    req, "CREATE", &path, &e, ctx,
1372                )))
1373            }
1374        };
1375
1376        // Opening a handle is an access decision, so it takes an affirmative answer. Which of
1377        // the two the model picks reaches the wire as FILE_ATTRIBUTE_DIRECTORY, which is what
1378        // makes a client issue QUERY_DIRECTORY instead of READ. Neither present is a refusal:
1379        // silence must not become consent for an admission decision.
1380        let action_type =
1381            |a: &serde_json::Value| a.get("type").and_then(|t| t.as_str()).map(String::from);
1382        let dir_action = actions
1383            .iter()
1384            .find(|a| action_type(a).as_deref() == Some("smb_create_directory"));
1385        let file_action = actions
1386            .iter()
1387            .find(|a| action_type(a).as_deref() == Some("smb_create_file"));
1388        let is_directory = dir_action.is_some();
1389        if dir_action.is_none() && file_action.is_none() {
1390            let decision = if actions.is_empty() {
1391                "fail_closed_no_action"
1392            } else {
1393                "model_reject"
1394            };
1395            Log::new(Some(ctx.status_tx)).warn(format!(
1396                "SMB2 CREATE refused for {} (decision={}): no smb_create_file or \
1397                 smb_create_directory in the answer; replying STATUS_ACCESS_DENIED",
1398                path, decision
1399            ));
1400            return Ok(Some(wire::error_response(
1401                &hdr.with_status(status::ACCESS_DENIED),
1402            )));
1403        }
1404        if is_directory && options & FILE_NON_DIRECTORY_FILE != 0 {
1405            return Ok(Some(wire::error_response(
1406                &hdr.with_status(STATUS_FILE_IS_A_DIRECTORY),
1407            )));
1408        }
1409        if !is_directory && options & FILE_DIRECTORY_FILE != 0 {
1410            return Ok(Some(wire::error_response(
1411                &hdr.with_status(STATUS_NOT_A_DIRECTORY),
1412            )));
1413        }
1414
1415        // The size the open reports as EndOfFile. A client may trust it and read no further
1416        // (smbprotocol does), so without it the file reads as empty until a QUERY_INFO asks.
1417        let declared_size = file_action
1418            .filter(|_| !is_directory)
1419            .and_then(|a| a.get("size"))
1420            .and_then(|v| v.as_u64());
1421        let (file_id, meta) = {
1422            let mut s = ctx.state.lock().await;
1423            let index = s.next_file_index;
1424            s.next_file_index += 1;
1425            let file_id = file_id_for(index);
1426            let meta = FileMeta {
1427                is_directory,
1428                size: declared_size.unwrap_or(0),
1429                time: 0,
1430                file_index: index,
1431            };
1432            s.files.insert(
1433                file_id,
1434                SmbFileHandle {
1435                    path: path.clone(),
1436                    tree_id: req.tree_id,
1437                    meta,
1438                    size_known: is_directory || declared_size.is_some(),
1439                    listing: None,
1440                },
1441            );
1442            (file_id, meta)
1443        };
1444        chain.file_id = Some(file_id);
1445
1446        debug!(
1447            "SMB2 CREATE: allocated {} handle for {}",
1448            if is_directory { "directory" } else { "file" },
1449            path
1450        );
1451        let create_action = if disposition == FILE_CREATE {
1452            wire::FILE_CREATED
1453        } else {
1454            wire::FILE_OPENED
1455        };
1456        Ok(Some(wire::create_response(
1457            &hdr,
1458            &file_id,
1459            &meta,
1460            create_action,
1461        )))
1462    }
1463
1464    /// CLOSE (MS-SMB2 3.3.5.10).
1465    async fn close(
1466        req: &RequestHeader,
1467        body: &[u8],
1468        chain: &Chain,
1469        ctx: &Ctx<'_>,
1470    ) -> Result<Option<Vec<u8>>> {
1471        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1472        let Some((flags, file_id)) = wire::parse_close(body) else {
1473            return Ok(Some(wire::error_response(
1474                &hdr.with_status(status::INVALID_PARAMETER),
1475            )));
1476        };
1477        let Some((file_id, handle)) = Self::handle(ctx, req, file_id, chain).await else {
1478            Log::new(Some(ctx.status_tx)).warn("SMB2 CLOSE: unknown file handle");
1479            return Ok(Some(wire::error_response(
1480                &hdr.with_status(status::FILE_CLOSED),
1481            )));
1482        };
1483        ctx.state.lock().await.files.remove(&file_id);
1484        Log::new(Some(ctx.status_tx)).info(format!("SMB2 CLOSE: {}", handle.path));
1485        let meta = (flags & wire::CLOSE_FLAG_POSTQUERY_ATTRIB != 0).then_some(&handle.meta);
1486        Ok(Some(wire::close_response(&hdr, meta)))
1487    }
1488
1489    /// READ (MS-SMB2 3.3.5.12): the model supplies the whole file; the server answers the
1490    /// range the client asked for.
1491    async fn read(
1492        req: &RequestHeader,
1493        body: &[u8],
1494        chain: &Chain,
1495        ctx: &Ctx<'_>,
1496    ) -> Result<Option<Vec<u8>>> {
1497        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1498        let Some(request) = wire::parse_read(body) else {
1499            return Ok(Some(wire::error_response(
1500                &hdr.with_status(status::INVALID_PARAMETER),
1501            )));
1502        };
1503        if request.length > MAX_READ_SIZE {
1504            Log::new(Some(ctx.status_tx)).warn(format!(
1505                "SMB2 READ of {} bytes refused (decision=fail_closed_read_too_large, \
1506                 MaxReadSize {}); replying STATUS_INVALID_PARAMETER",
1507                request.length, MAX_READ_SIZE
1508            ));
1509            return Ok(Some(wire::error_response(
1510                &hdr.with_status(status::INVALID_PARAMETER),
1511            )));
1512        }
1513        let length = request.length;
1514        let offset = request.offset;
1515        let Some((_, handle)) = Self::handle(ctx, req, request.file_id, chain).await else {
1516            return Ok(Some(wire::error_response(
1517                &hdr.with_status(status::FILE_CLOSED),
1518            )));
1519        };
1520        let path = handle.path;
1521        if handle.meta.is_directory {
1522            return Ok(Some(wire::error_response(
1523                &hdr.with_status(status::INVALID_DEVICE_REQUEST),
1524            )));
1525        }
1526        Log::new(Some(ctx.status_tx)).info(format!(
1527            "SMB2 READ: {} (offset={}, length={})",
1528            path, offset, length
1529        ));
1530
1531        // On an LLM failure the READ is refused with an NTSTATUS rather than answered with
1532        // invented content.
1533        let actions = match Self::consult_llm(
1534            ctx,
1535            "read",
1536            serde_json::json!({ "path": path, "offset": offset, "length": length }),
1537        )
1538        .await
1539        {
1540            Ok(actions) => actions,
1541            Err(e) => {
1542                return Ok(Some(Self::llm_failure_response(
1543                    req, "READ", &path, &e, ctx,
1544                )))
1545            }
1546        };
1547
1548        // `content` is only base64 or hex when the action says so, because "SGVsbG8=" is
1549        // simultaneously valid text and valid base64 and only the sender knows which it means.
1550        let Some(action) = actions
1551            .iter()
1552            .find(|a| a.get("type").and_then(|t| t.as_str()) == Some("smb_read_file"))
1553        else {
1554            // A READ answered with no `smb_read_file` is refused. Answering STATUS_SUCCESS with
1555            // placeholder bytes would tell the client those bytes are the file.
1556            let decision = if actions.is_empty() {
1557                "fail_closed_no_action"
1558            } else {
1559                "model_reject"
1560            };
1561            Log::new(Some(ctx.status_tx)).warn(format!(
1562                "SMB2 READ refused for {} (decision={}): no smb_read_file in the answer; \
1563                 replying STATUS_ACCESS_DENIED",
1564                path, decision
1565            ));
1566            return Ok(Some(wire::error_response(
1567                &hdr.with_status(status::ACCESS_DENIED),
1568            )));
1569        };
1570        let payload = action
1571            .get("content")
1572            .and_then(|c| c.as_str())
1573            .unwrap_or_default();
1574        let encoding = action.get("encoding").and_then(|e| e.as_str());
1575        let content = match actions::decode_smb_payload(payload, encoding) {
1576            Ok(bytes) => bytes,
1577            Err(e) => {
1578                // Refuse rather than putting the undecodable string on the wire.
1579                Log::new(Some(ctx.status_tx))
1580                    .warn(format!("SMB read: {} - refusing with STATUS_DATA_ERROR", e));
1581                return Ok(Some(wire::error_response(
1582                    &hdr.with_status(status::DATA_ERROR),
1583                )));
1584            }
1585        };
1586
1587        // MS-SMB2 3.3.5.12: a read starting at or past the end of the file is END_OF_FILE.
1588        let len = content.len() as u64;
1589        if offset >= len && !(offset == 0 && length == 0) {
1590            debug!(
1591                "SMB2 READ at {} of a {} byte file: END_OF_FILE",
1592                offset, len
1593            );
1594            return Ok(Some(wire::error_response(
1595                &hdr.with_status(status::END_OF_FILE),
1596            )));
1597        }
1598        let start = offset as usize;
1599        let end = (offset.saturating_add(length as u64)).min(len) as usize;
1600        let data = &content[start..end];
1601        debug!("SMB2 READ: returning {} bytes of {}", data.len(), path);
1602        Ok(Some(wire::read_response(&hdr, data)))
1603    }
1604
1605    /// WRITE (MS-SMB2 3.3.5.13): the model authorises the write; nothing is stored.
1606    async fn write(
1607        req: &RequestHeader,
1608        message: &[u8],
1609        chain: &Chain,
1610        ctx: &Ctx<'_>,
1611    ) -> Result<Option<Vec<u8>>> {
1612        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1613        let Some(request) = wire::parse_write(message) else {
1614            return Ok(Some(wire::error_response(
1615                &hdr.with_status(status::INVALID_PARAMETER),
1616            )));
1617        };
1618        let length = request.length;
1619        let offset = request.offset;
1620
1621        if length > MAX_WRITE_SIZE {
1622            Log::new(Some(ctx.status_tx)).warn(format!(
1623                "SMB2 WRITE: refusing {} byte write (MaxWriteSize {}) \
1624                 decision=fail_closed_write_too_large",
1625                length, MAX_WRITE_SIZE
1626            ));
1627            return Ok(Some(wire::error_response(
1628                &hdr.with_status(status::INVALID_PARAMETER),
1629            )));
1630        }
1631        let Some(data) = request.data else {
1632            Log::new(Some(ctx.status_tx)).warn(format!(
1633                "SMB2 WRITE declares {} bytes past the end of a {} byte message; replying \
1634                 STATUS_INVALID_PARAMETER",
1635                length,
1636                message.len()
1637            ));
1638            return Ok(Some(wire::error_response(
1639                &hdr.with_status(status::INVALID_PARAMETER),
1640            )));
1641        };
1642
1643        let Some((_, handle)) = Self::handle(ctx, req, request.file_id, chain).await else {
1644            return Ok(Some(wire::error_response(
1645                &hdr.with_status(status::FILE_CLOSED),
1646            )));
1647        };
1648        let path = handle.path;
1649        Log::new(Some(ctx.status_tx)).info(format!(
1650            "SMB2 WRITE: {} (offset={}, length={})",
1651            path, offset, length
1652        ));
1653
1654        // Printable payloads stay readable; anything else is base64, and `encoding` says which,
1655        // matching what smb_read_file accepts so the model can hand the same bytes back.
1656        let (content, data_encoding) = actions::encode_smb_payload(data);
1657
1658        // The write is refused unless the model returns smb_write_file: an LLM outage or a
1659        // model that says nothing must not read as an approval.
1660        let actions = match Self::consult_llm(
1661            ctx,
1662            "write",
1663            serde_json::json!({
1664                "path": path,
1665                "offset": offset,
1666                "data": content,
1667                "encoding": data_encoding
1668            }),
1669        )
1670        .await
1671        {
1672            Ok(actions) => actions,
1673            Err(e) => {
1674                return Ok(Some(Self::llm_failure_response(
1675                    req, "WRITE", &path, &e, ctx,
1676                )))
1677            }
1678        };
1679
1680        let Some(write_action) = actions
1681            .iter()
1682            .find(|a| a.get("type").and_then(|t| t.as_str()) == Some("smb_write_file"))
1683        else {
1684            let decision = if actions.is_empty() {
1685                "fail_closed_no_action"
1686            } else {
1687                "model_reject"
1688            };
1689            Log::new(Some(ctx.status_tx)).warn(format!(
1690                "SMB2 WRITE refused for {} (decision={}): no smb_write_file in the answer; \
1691                 replying STATUS_ACCESS_DENIED",
1692                path, decision
1693            ));
1694            return Ok(Some(wire::error_response(
1695                &hdr.with_status(status::ACCESS_DENIED),
1696            )));
1697        };
1698
1699        // The model may report a short write; clamp to what the client actually sent.
1700        let bytes_written = write_action
1701            .get("bytes_written")
1702            .and_then(|v| v.as_u64())
1703            .map(|v| v.min(length as u64) as u32)
1704            .unwrap_or(length);
1705        debug!(
1706            "SMB2 WRITE: accepted {} of {} bytes to {}",
1707            bytes_written, length, path
1708        );
1709        Ok(Some(wire::write_response(&hdr, bytes_written)))
1710    }
1711
1712    /// QUERY_INFO (MS-SMB2 3.3.5.20). File-system classes and every class that does not need
1713    /// the file's size are answered from the handle; the size is the model's.
1714    async fn query_info(
1715        req: &RequestHeader,
1716        body: &[u8],
1717        chain: &Chain,
1718        ctx: &Ctx<'_>,
1719    ) -> Result<Option<Vec<u8>>> {
1720        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1721        let Some(request) = wire::parse_query_info(body) else {
1722            warn!("SMB2 QUERY_INFO: invalid request size");
1723            return Ok(Some(wire::error_response(
1724                &hdr.with_status(status::INVALID_PARAMETER),
1725            )));
1726        };
1727        if request.output_len > MAX_TRANSACT_SIZE {
1728            return Ok(Some(Self::transact_too_large(req, request.output_len, ctx)));
1729        }
1730        let info_type = request.info_type;
1731        let class = request.class;
1732        let output_len = request.output_len as usize;
1733        let Some((file_id, handle)) = Self::handle(ctx, req, request.file_id, chain).await else {
1734            return Ok(Some(wire::error_response(
1735                &hdr.with_status(status::FILE_CLOSED),
1736            )));
1737        };
1738        let path = handle.path.clone();
1739
1740        let buffer = match info_type {
1741            wire::INFO_FILESYSTEM => {
1742                let share = ctx
1743                    .state
1744                    .lock()
1745                    .await
1746                    .trees
1747                    .get(&req.tree_id)
1748                    .map(|t| t.share_name.clone())
1749                    .unwrap_or_default();
1750                match wire::fs_info(class, &share, 0) {
1751                    Some(buffer) => buffer,
1752                    None => {
1753                        return Ok(Some(wire::error_response(
1754                            &hdr.with_status(status::INVALID_INFO_CLASS),
1755                        )))
1756                    }
1757                }
1758            }
1759            wire::INFO_FILE => {
1760                let mut meta = handle.meta;
1761                if wire::file_class_needs_size(class) && !handle.size_known {
1762                    Log::new(Some(ctx.status_tx)).info(format!("SMB2 QUERY_INFO: {}", path));
1763                    let actions = match Self::consult_llm(
1764                        ctx,
1765                        "query_info",
1766                        serde_json::json!({ "path": path }),
1767                    )
1768                    .await
1769                    {
1770                        Ok(actions) => actions,
1771                        Err(e) => {
1772                            return Ok(Some(Self::llm_failure_response(
1773                                req,
1774                                "QUERY_INFO",
1775                                &path,
1776                                &e,
1777                                ctx,
1778                            )))
1779                        }
1780                    };
1781                    // No `smb_get_file_info` with a size is a refusal, not a zero-byte file.
1782                    let info = actions.iter().find(|a| {
1783                        a.get("type").and_then(|t| t.as_str()) == Some("smb_get_file_info")
1784                    });
1785                    let Some(size) = info.and_then(|a| a.get("size")).and_then(|s| s.as_u64())
1786                    else {
1787                        let decision = if actions.is_empty() {
1788                            "fail_closed_no_action"
1789                        } else {
1790                            "model_reject"
1791                        };
1792                        Log::new(Some(ctx.status_tx)).warn(format!(
1793                            "SMB2 QUERY_INFO refused for {} (decision={}): no \
1794                             smb_get_file_info with a size in the answer; replying \
1795                             STATUS_ACCESS_DENIED",
1796                            path, decision
1797                        ));
1798                        return Ok(Some(wire::error_response(
1799                            &hdr.with_status(status::ACCESS_DENIED),
1800                        )));
1801                    };
1802                    meta.size = size;
1803                    if let Some(time) = info
1804                        .and_then(|a| a.get("modified_time"))
1805                        .and_then(|t| t.as_str())
1806                        .and_then(parse_time)
1807                    {
1808                        meta.time = time;
1809                    }
1810                    if let Some(h) = ctx.state.lock().await.files.get_mut(&file_id) {
1811                        h.meta = meta;
1812                        h.size_known = true;
1813                    }
1814                }
1815                match wire::file_info(class, &meta) {
1816                    Some(buffer) => buffer,
1817                    None => {
1818                        return Ok(Some(wire::error_response(
1819                            &hdr.with_status(status::INVALID_INFO_CLASS),
1820                        )))
1821                    }
1822                }
1823            }
1824            _ => {
1825                // Security descriptors and quotas: neither exists here.
1826                return Ok(Some(wire::error_response(
1827                    &hdr.with_status(status::NOT_SUPPORTED),
1828                )));
1829            }
1830        };
1831
1832        if buffer.len() > output_len {
1833            return Ok(Some(wire::error_response(
1834                &hdr.with_status(STATUS_INFO_LENGTH_MISMATCH),
1835            )));
1836        }
1837        Ok(Some(wire::query_info_response(&hdr, &buffer)))
1838    }
1839
1840    /// QUERY_DIRECTORY (MS-SMB2 3.3.5.18). The first call of an enumeration asks the model for
1841    /// the listing; the entries are handed out across as many calls as the client's buffer
1842    /// needs, and the call after the last entry answers `STATUS_NO_MORE_FILES`.
1843    async fn query_directory(
1844        req: &RequestHeader,
1845        message: &[u8],
1846        chain: &Chain,
1847        ctx: &Ctx<'_>,
1848    ) -> Result<Option<Vec<u8>>> {
1849        let hdr = ResponseHeader::for_request(req, status::SUCCESS);
1850        let Some(wire::QueryDirectoryRequest {
1851            class,
1852            flags,
1853            file_id,
1854            pattern,
1855            output_len,
1856        }) = wire::parse_query_directory(message)
1857        else {
1858            warn!("SMB2 QUERY_DIRECTORY: invalid request");
1859            return Ok(Some(wire::error_response(
1860                &hdr.with_status(status::INVALID_PARAMETER),
1861            )));
1862        };
1863        if output_len > MAX_TRANSACT_SIZE {
1864            return Ok(Some(Self::transact_too_large(req, output_len, ctx)));
1865        }
1866        let output_len = output_len as usize;
1867        if !wire::directory_class_supported(class) {
1868            return Ok(Some(wire::error_response(
1869                &hdr.with_status(status::INVALID_INFO_CLASS),
1870            )));
1871        }
1872        let Some((file_id, handle)) = Self::handle(ctx, req, file_id, chain).await else {
1873            return Ok(Some(wire::error_response(
1874                &hdr.with_status(status::FILE_CLOSED),
1875            )));
1876        };
1877        if !handle.meta.is_directory {
1878            return Ok(Some(wire::error_response(
1879                &hdr.with_status(status::INVALID_PARAMETER),
1880            )));
1881        }
1882        let path = handle.path.clone();
1883
1884        let restart = flags & (RESTART_SCANS | REOPEN) != 0 || handle.listing.is_none();
1885        if restart {
1886            Log::new(Some(ctx.status_tx))
1887                .info(format!("SMB2 QUERY_DIRECTORY: {} ({})", path, pattern));
1888            let actions = match Self::consult_llm(
1889                ctx,
1890                "query_directory",
1891                serde_json::json!({ "path": path, "pattern": pattern }),
1892            )
1893            .await
1894            {
1895                Ok(actions) => actions,
1896                Err(e) => {
1897                    return Ok(Some(Self::llm_failure_response(
1898                        req,
1899                        "QUERY_DIRECTORY",
1900                        &path,
1901                        &e,
1902                        ctx,
1903                    )))
1904                }
1905            };
1906
1907            // No `smb_list_directory` is a refusal: an empty listing with STATUS_SUCCESS reads
1908            // to the client as "the directory is empty", a positive assertion the model never
1909            // made. An empty `files` array is how the model says the directory is empty.
1910            let Some(files) = actions
1911                .iter()
1912                .find(|a| a.get("type").and_then(|t| t.as_str()) == Some("smb_list_directory"))
1913                .and_then(|a| a.get("files"))
1914                .and_then(|f| f.as_array())
1915                .cloned()
1916            else {
1917                let decision = if actions.is_empty() {
1918                    "fail_closed_no_action"
1919                } else {
1920                    "model_reject"
1921                };
1922                Log::new(Some(ctx.status_tx)).warn(format!(
1923                    "SMB2 QUERY_DIRECTORY refused for {} (decision={}): no smb_list_directory \
1924                     in the answer; replying STATUS_ACCESS_DENIED",
1925                    path, decision
1926                ));
1927                return Ok(Some(wire::error_response(
1928                    &hdr.with_status(status::ACCESS_DENIED),
1929                )));
1930            };
1931
1932            let mut entries = VecDeque::new();
1933            let mut s = ctx.state.lock().await;
1934            let mut next_index = || {
1935                let i = s.next_file_index;
1936                s.next_file_index += 1;
1937                i
1938            };
1939            for dot in [".", ".."] {
1940                if wildcard_match(&pattern, dot) {
1941                    let meta = FileMeta {
1942                        is_directory: true,
1943                        file_index: next_index(),
1944                        ..FileMeta::default()
1945                    };
1946                    entries.push_back((dot.to_string(), meta));
1947                }
1948            }
1949            for file in &files {
1950                let Some(name) = file.get("name").and_then(|n| n.as_str()) else {
1951                    continue;
1952                };
1953                // A listing names children, not paths.
1954                let name = name.rsplit(['/', '\\']).next().unwrap_or(name);
1955                if name.is_empty() || name == "." || name == ".." || !wildcard_match(&pattern, name)
1956                {
1957                    continue;
1958                }
1959                let meta = FileMeta {
1960                    is_directory: file
1961                        .get("is_directory")
1962                        .and_then(|d| d.as_bool())
1963                        .unwrap_or(false),
1964                    size: file.get("size").and_then(|s| s.as_u64()).unwrap_or(0),
1965                    time: file
1966                        .get("modified_time")
1967                        .and_then(|t| t.as_str())
1968                        .and_then(parse_time)
1969                        .unwrap_or(0),
1970                    file_index: next_index(),
1971                };
1972                entries.push_back((name.to_string(), meta));
1973            }
1974            debug!(
1975                "SMB2 QUERY_DIRECTORY: {} entries for {}",
1976                entries.len(),
1977                path
1978            );
1979            if let Some(h) = s.files.get_mut(&file_id) {
1980                h.listing = Some(entries);
1981            }
1982        }
1983
1984        // Hand out as many pending entries as fit.
1985        let mut s = ctx.state.lock().await;
1986        let Some(pending) = s.files.get_mut(&file_id).and_then(|h| h.listing.as_mut()) else {
1987            return Ok(Some(wire::error_response(
1988                &hdr.with_status(status::FILE_CLOSED),
1989            )));
1990        };
1991        if pending.is_empty() {
1992            return Ok(Some(wire::error_response(
1993                &hdr.with_status(status::NO_MORE_FILES),
1994            )));
1995        }
1996        let mut out: Vec<Vec<u8>> = Vec::new();
1997        let mut used = 0usize;
1998        while let Some((name, meta)) = pending.front() {
1999            let entry = wire::directory_entry(class, name, meta);
2000            // Every entry already in `out` gets padded once this one follows it.
2001            let cost = used + entry.len();
2002            if cost > output_len {
2003                break;
2004            }
2005            used += wire::padded_len(&entry);
2006            out.push(entry);
2007            pending.pop_front();
2008            if flags & RETURN_SINGLE_ENTRY != 0 {
2009                break;
2010            }
2011        }
2012        if out.is_empty() {
2013            return Ok(Some(wire::error_response(
2014                &hdr.with_status(STATUS_INFO_LENGTH_MISMATCH),
2015            )));
2016        }
2017        Ok(Some(wire::query_directory_response(
2018            &hdr,
2019            &wire::join_directory_entries(&out),
2020        )))
2021    }
2022
2023    /// The refusal for a QUERY_INFO or QUERY_DIRECTORY whose `OutputBufferLength` is past
2024    /// [`MAX_TRANSACT_SIZE`] (MS-SMB2 3.3.5.18, 3.3.5.20).
2025    fn transact_too_large(req: &RequestHeader, output_len: u32, ctx: &Ctx<'_>) -> Vec<u8> {
2026        Log::new(Some(ctx.status_tx)).warn(format!(
2027            "SMB2 command 0x{:04x} asks for a {} byte output buffer \
2028             (decision=fail_closed_transact_too_large, MaxTransactSize {}); replying \
2029             STATUS_INVALID_PARAMETER",
2030            req.command, output_len, MAX_TRANSACT_SIZE
2031        ));
2032        wire::error_response(&ResponseHeader::for_request(req, status::INVALID_PARAMETER))
2033    }
2034
2035    /// Consult the LLM for SMB file system operations
2036    async fn consult_llm(
2037        ctx: &Ctx<'_>,
2038        operation: &str,
2039        params: serde_json::Value,
2040    ) -> Result<Vec<serde_json::Value>> {
2041        Log::new(Some(ctx.status_tx)).debug(format!(
2042            "Consulting LLM for SMB {} operation: {:?}",
2043            operation, params
2044        ));
2045
2046        let mut event_data = serde_json::json!({
2047            "operation": operation,
2048        });
2049        if let Some(obj) = params.as_object() {
2050            for (key, value) in obj {
2051                if key == "path" && value.as_str().is_some_and(str::is_empty) {
2052                    continue;
2053                }
2054                if key != "operation" {
2055                    event_data[key] = value.clone();
2056                }
2057            }
2058        }
2059
2060        let event = Event::new(&SMB_OPERATION_EVENT, event_data);
2061
2062        Log::new(Some(ctx.status_tx)).trace(format!("Calling LLM for SMB {} operation", operation));
2063
2064        let execution_result = call_llm(
2065            ctx.llm_client,
2066            ctx.app_state,
2067            ctx.server_id,
2068            None, // SMB doesn't use connection-specific context yet
2069            &event,
2070            ctx.protocol.as_ref(),
2071        )
2072        .await?;
2073
2074        for message in &execution_result.messages {
2075            Log::new(Some(ctx.status_tx)).info(message.to_string());
2076        }
2077
2078        debug!(
2079            "LLM returned {} actions for SMB {}",
2080            execution_result.raw_actions.len(),
2081            operation
2082        );
2083
2084        Ok(execution_result.raw_actions)
2085    }
2086
2087    /// Refuse an operation because the LLM call failed, in SMB2's own vocabulary.
2088    ///
2089    /// Fail closed: the request is answered with an NTSTATUS failure, never with
2090    /// STATUS_SUCCESS and invented content, and never with silence.
2091    ///
2092    /// `STATUS_INSUFFICIENT_RESOURCES` (0xC000009A) is used when
2093    /// `crate::llm::is_overload_error` identifies capacity exhaustion, because it is the
2094    /// closest NTSTATUS to "retryable"; every other failure is `STATUS_INTERNAL_ERROR`
2095    /// (0xC00000E5). Both stay distinguishable from the model's own refusal
2096    /// (STATUS_ACCESS_DENIED) and from an undecodable payload (STATUS_DATA_ERROR).
2097    fn llm_failure_response(
2098        req: &RequestHeader,
2099        operation: &str,
2100        path: &str,
2101        err: &anyhow::Error,
2102        ctx: &Ctx<'_>,
2103    ) -> Vec<u8> {
2104        let overloaded = crate::llm::is_overload_error(err);
2105        let code = status_for_llm_failure(err);
2106
2107        Log::new(Some(ctx.status_tx)).warn(format!(
2108            "SMB {} {}: LLM {} (decision=fail_closed_llm_error) - refusing with NTSTATUS \
2109             0x{:08X}: {}",
2110            operation,
2111            path,
2112            if overloaded {
2113                "overloaded"
2114            } else {
2115                "backend failure"
2116            },
2117            code,
2118            err
2119        ));
2120
2121        wire::error_response(&ResponseHeader::for_request(req, code))
2122    }
2123}
2124
2125fn disposition_name(disposition: u32) -> &'static str {
2126    match disposition {
2127        0 => "supersede",
2128        1 => "open",
2129        2 => "create",
2130        3 => "open_if",
2131        4 => "overwrite",
2132        5 => "overwrite_if",
2133        _ => "unknown",
2134    }
2135}
2136
2137/// A FileId for the `index`th open: persistent and volatile halves derived from one counter.
2138fn file_id_for(index: u64) -> [u8; 16] {
2139    let mut id = [0u8; 16];
2140    id[..8].copy_from_slice(&index.to_le_bytes());
2141    id[8..].copy_from_slice(&(index ^ 0x4E47_5342_0000_0000).to_le_bytes());
2142    id
2143}
2144
2145/// The NTSTATUS a request is refused with when the model could not be asked:
2146/// `STATUS_INSUFFICIENT_RESOURCES` when `crate::llm::is_overload_error` recognises a capacity
2147/// refusal (the closest NTSTATUS to "retryable"), `STATUS_INTERNAL_ERROR` for everything else.
2148/// Neither is `STATUS_ACCESS_DENIED`, which is the model's own refusal.
2149pub fn status_for_llm_failure(err: &anyhow::Error) -> u32 {
2150    if crate::llm::is_overload_error(err) {
2151        status::INSUFFICIENT_RESOURCES
2152    } else {
2153        status::INTERNAL_ERROR
2154    }
2155}
2156
2157/// Case-insensitive match of an SMB search pattern with `*` and `?`.
2158fn wildcard_match(pattern: &str, name: &str) -> bool {
2159    let p: Vec<char> = pattern.to_lowercase().chars().collect();
2160    let n: Vec<char> = name.to_lowercase().chars().collect();
2161    let (mut pi, mut ni) = (0usize, 0usize);
2162    let (mut star, mut mark) = (None::<usize>, 0usize);
2163    while ni < n.len() {
2164        if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
2165            pi += 1;
2166            ni += 1;
2167        } else if pi < p.len() && p[pi] == '*' {
2168            star = Some(pi);
2169            mark = ni;
2170            pi += 1;
2171        } else if let Some(s) = star {
2172            pi = s + 1;
2173            mark += 1;
2174            ni = mark;
2175        } else {
2176            return false;
2177        }
2178    }
2179    while pi < p.len() && p[pi] == '*' {
2180        pi += 1;
2181    }
2182    pi == p.len()
2183}
2184
2185/// An RFC 3339 timestamp as a FILETIME.
2186fn parse_time(s: &str) -> Option<u64> {
2187    let t = chrono::DateTime::parse_from_rfc3339(s).ok()?;
2188    Some(wire::filetime_from_unix(
2189        t.timestamp(),
2190        t.timestamp_subsec_nanos(),
2191    ))
2192}
2193
2194fn filetime_now() -> u64 {
2195    let now = crate::utils::clock::SystemTime::now()
2196        .duration_since(crate::utils::clock::UNIX_EPOCH)
2197        .unwrap_or_default();
2198    wire::filetime_from_unix(now.as_secs() as i64, now.subsec_nanos())
2199}
2200
2201/// The NTLMSSP server challenge. Nothing is ever verified against it, so it needs to vary, not
2202/// to be secret.
2203fn server_challenge() -> [u8; 8] {
2204    let t = filetime_now();
2205    (t ^ t.rotate_left(29) ^ 0x9E37_79B9_7F4A_7C15).to_le_bytes()
2206}
2207
2208/// The connection's read half plus the counters the dashboard's `↓` column reads.
2209///
2210/// Reads accumulate here and the session loop flushes them to `AppState` once per frame.
2211struct SmbReader<R> {
2212    inner: R,
2213    /// Bytes read since the last flush.
2214    pending_bytes: u64,
2215    /// Completed reads since the last flush.
2216    pending_reads: u64,
2217}
2218
2219impl<R: tokio::io::AsyncRead + Unpin> SmbReader<R> {
2220    fn new(inner: R) -> Self {
2221        Self {
2222            inner,
2223            pending_bytes: 0,
2224            pending_reads: 0,
2225        }
2226    }
2227
2228    /// `read_exact`, counting the bytes on success. A cancelled read (the caller's timeout
2229    /// firing) has already lost whatever it consumed, so there is nothing honest to count.
2230    async fn read_exact_counted(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2231        let result = self.inner.read_exact(buf).await;
2232        if let Ok(n) = result {
2233            self.pending_bytes += n as u64;
2234            self.pending_reads += 1;
2235        }
2236        result
2237    }
2238
2239    /// Take and clear the counters.
2240    fn take_pending(&mut self) -> (u64, u64) {
2241        (
2242            std::mem::take(&mut self.pending_bytes),
2243            std::mem::take(&mut self.pending_reads),
2244        )
2245    }
2246}
2247
2248/// Fold whatever the reader has counted since the last call into the connection's stats.
2249async fn flush_read_stats<R: tokio::io::AsyncRead + Unpin>(
2250    reader: &mut SmbReader<R>,
2251    app_state: &AppState,
2252    server_id: ServerId,
2253    connection_id: ConnectionId,
2254) {
2255    let (bytes, reads) = reader.take_pending();
2256    if bytes == 0 && reads == 0 {
2257        return;
2258    }
2259    app_state
2260        .update_connection_stats(
2261            server_id,
2262            connection_id,
2263            Some(bytes),
2264            None,
2265            Some(reads),
2266            None,
2267        )
2268        .await;
2269}
2270
2271/// Write one frame to the peer and count it. The guard is dropped before the stats update so
2272/// nothing awaits `AppState` while holding the write half — the peer command task needs the
2273/// same lock to inject a message or a disconnect.
2274async fn write_counted<W>(
2275    write_half: &Arc<Mutex<W>>,
2276    data: &[u8],
2277    ctx: &Ctx<'_>,
2278) -> std::io::Result<()>
2279where
2280    W: tokio::io::AsyncWrite + Unpin,
2281{
2282    {
2283        let mut writer = write_half.lock().await;
2284        writer.write_all(data).await?;
2285        writer.flush().await?;
2286    }
2287    ctx.app_state
2288        .update_connection_stats(
2289            ctx.server_id,
2290            ctx.connection_id,
2291            None,
2292            Some(data.len() as u64),
2293            None,
2294            Some(1),
2295        )
2296        .await;
2297    Ok(())
2298}
2299
2300/// Read exactly `buf.len()` bytes of a message the peer has already announced, bounded by
2301/// `deadline` ([`BODY_READ_TIMEOUT`] unless configured).
2302async fn read_body_exact<R: tokio::io::AsyncRead + Unpin>(
2303    stream: &mut SmbReader<R>,
2304    buf: &mut [u8],
2305    deadline: std::time::Duration,
2306) -> std::io::Result<()> {
2307    match tokio::time::timeout(deadline, stream.read_exact_counted(buf)).await {
2308        Ok(read) => read.map(|_| ()),
2309        Err(_) => Err(std::io::Error::new(
2310            std::io::ErrorKind::TimedOut,
2311            "peer stalled part-way through an announced SMB2 message",
2312        )),
2313    }
2314}