Skip to main content

netget/server/nfs/
mod.rs

1//! NFSv3 server built on `nfsserve`, with the filesystem supplied entirely by the LLM.
2//!
3//! `nfsserve` owns RPC, XDR and the MOUNT protocol; `LlmNfsFileSystem` below implements its
4//! `NFSFileSystem` trait by raising an `nfs_operation` event for every procedure and reading
5//! the answer out of the model's response action. **There is no storage here** - no file
6//! table, no directory tree, no attribute cache. Consistency across calls (a file ID meaning
7//! the same file twice, a directory listing matching what lookup returns) is the model's
8//! responsibility, which is why the server instruction carries so much weight.
9//!
10//! Two consequences worth knowing before using it:
11//!
12//! - **One model round-trip per NFS procedure.** A `ls` is several. Real workloads are
13//!   impractically slow; script or static handlers are the answer for anything repetitive.
14//! - **Text only.** File contents travel as UTF-8 strings in JSON actions because actions must
15//!   not carry raw bytes or base64. Outbound data is written verbatim; inbound writes go
16//!   through `String::from_utf8_lossy`, so a client writing binary hands the model U+FFFD.
17//!   Binary files cannot be served or received faithfully, and there is no encoded fallback.
18//!
19//! A third thing worth knowing before reading `spawn_with_llm_actions`: **NetGet owns the
20//! listening socket, not `nfsserve`.** The crate sizes buffers from a wire-supplied length
21//! with no cap, which made a forty-byte unauthenticated MOUNT a whole-process denial of
22//! service, and `NFSTcpListener` exposes no seam to fix that inside it. So `nfsserve` runs on
23//! a loopback-only ephemeral port and every connection is relayed to it through
24//! [`guard::serve_screened`], which refuses over-long RPC records before the crate sees them.
25pub mod actions;
26pub mod guard;
27
28use anyhow::{Context, Result};
29use std::net::SocketAddr;
30use std::sync::Arc;
31use tokio::sync::mpsc;
32use tracing::{debug, error, info, warn};
33
34#[cfg(unix)]
35use async_trait::async_trait;
36#[cfg(unix)]
37use nfsserve::nfs::{fattr3, fileid3, filename3, ftype3, nfspath3, nfsstat3, nfstime3, sattr3};
38#[cfg(unix)]
39use nfsserve::vfs::{DirEntry, NFSFileSystem, ReadDirResult};
40
41use crate::llm::action_helper::call_llm;
42use crate::llm::ollama_client::OllamaClient;
43use crate::logging::emit::Log;
44use crate::protocol::Event;
45use crate::server::NfsProtocol;
46use crate::state::app_state::AppState;
47use actions::NFS_OPERATION_EVENT;
48
49/// NFS server that provides LLM-controlled file system
50pub struct NfsServer;
51
52impl NfsServer {
53    /// Spawn NFS server with integrated LLM actions
54    #[cfg(unix)]
55    pub async fn spawn_with_llm_actions(
56        listen_addr: SocketAddr,
57        llm_client: OllamaClient,
58        app_state: Arc<AppState>,
59        status_tx: mpsc::UnboundedSender<String>,
60        server_id: crate::state::ServerId,
61    ) -> Result<SocketAddr> {
62        use nfsserve::tcp::{NFSTcp, NFSTcpListener};
63
64        Log::new(Some(&status_tx)).info(format!(
65            "NFS server (LLM-controlled) starting on {}",
66            listen_addr
67        ));
68
69        let protocol = Arc::new(NfsProtocol::new());
70
71        // Create LLM-controlled filesystem
72        let filesystem = LlmNfsFileSystem::new(
73            llm_client,
74            app_state.clone(),
75            server_id,
76            protocol,
77            status_tx.clone(),
78        );
79
80        // The public listener is ours. `nfsserve` gets a loopback-only ephemeral port and
81        // never sees a byte that `guard` has not already sized - see `guard`'s module docs
82        // for what the crate does with an unchecked length, and why there is no way to fix
83        // it in place.
84        let public = tokio::net::TcpListener::bind(listen_addr)
85            .await
86            .with_context(|| format!("Failed to bind NFS listener on {}", listen_addr))?;
87        let actual_addr = public
88            .local_addr()
89            .context("Failed to read the NFS listener's address")?;
90
91        let nfs_listener = NFSTcpListener::bind("127.0.0.1:0", filesystem)
92            .await
93            .context("Failed to bind the internal NFS backend listener")?;
94        let backend_addr: SocketAddr =
95            SocketAddr::from(([127, 0, 0, 1], nfs_listener.get_listen_port()));
96
97        Log::new(Some(&status_tx)).info(format!(
98            "NFS server listening on {} (record screen in front of nfsserve on {})",
99            actual_addr, backend_addr
100        ));
101
102        // Spawn server handler
103        let backend_status_tx = status_tx.clone();
104        let backend_handle = tokio::spawn(async move {
105            info!("NFS server handler started");
106
107            // Handle connections forever (nfsserve manages connections internally)
108            if let Err(e) = nfs_listener.handle_forever().await {
109                Log::new(Some(&backend_status_tx)).error(format!("NFS server error: {}", e));
110            }
111        });
112
113        // Register the accept loops so stop_server can abort them and release the ports.
114        app_state
115            .register_server_task(server_id, backend_handle)
116            .await;
117
118        let screen_state = app_state.clone();
119        let screen_handle = tokio::spawn(async move {
120            guard::serve_screened(public, backend_addr, screen_state, server_id, status_tx).await;
121        });
122        app_state
123            .register_server_task(server_id, screen_handle)
124            .await;
125
126        Ok(actual_addr)
127    }
128
129    /// NFS needs Unix (nfsserve); elsewhere starting it fails with this error
130    #[cfg(not(unix))]
131    pub async fn spawn_with_llm_actions(
132        listen_addr: SocketAddr,
133        _llm_client: OllamaClient,
134        _app_state: Arc<AppState>,
135        status_tx: mpsc::UnboundedSender<String>,
136        _server_id: crate::state::ServerId,
137    ) -> Result<SocketAddr> {
138        Log::new(Some(&status_tx)).error("NFS feature not enabled at compile time");
139        Err(anyhow::anyhow!("NFS feature not enabled"))
140    }
141}
142
143/// LLM-controlled NFS filesystem implementation
144#[cfg(unix)]
145pub struct LlmNfsFileSystem {
146    llm_client: OllamaClient,
147    app_state: Arc<AppState>,
148    server_id: crate::state::ServerId,
149    protocol: Arc<NfsProtocol>,
150    status_tx: mpsc::UnboundedSender<String>,
151}
152
153#[cfg(unix)]
154impl LlmNfsFileSystem {
155    /// Create a new LLM-controlled NFS filesystem
156    pub fn new(
157        llm_client: OllamaClient,
158        app_state: Arc<AppState>,
159        server_id: crate::state::ServerId,
160        protocol: Arc<NfsProtocol>,
161        status_tx: mpsc::UnboundedSender<String>,
162    ) -> Self {
163        Self {
164            llm_client,
165            app_state,
166            server_id,
167            protocol,
168            status_tx,
169        }
170    }
171
172    /// Consult the LLM for NFS operations
173    async fn consult_llm(
174        &self,
175        operation: &str,
176        params: serde_json::Value,
177    ) -> Result<Vec<serde_json::Value>> {
178        Log::new(Some(&self.status_tx)).debug(format!(
179            "Consulting LLM for NFS {} operation: {:?}",
180            operation, params
181        ));
182
183        // Create NFS operation event
184        let event = Event::new(
185            &NFS_OPERATION_EVENT,
186            serde_json::json!({
187                "operation": operation,
188                "params": params
189            }),
190        );
191
192        Log::new(Some(&self.status_tx))
193            .trace(format!("Calling LLM for NFS {} operation", operation));
194
195        // Call LLM with Event-based approach
196        let execution_result = call_llm(
197            &self.llm_client,
198            &self.app_state,
199            self.server_id,
200            None, // NFS doesn't use connection-specific context
201            &event,
202            self.protocol.as_ref(),
203        )
204        .await?;
205
206        // Display messages from LLM
207        let log = Log::new(Some(&self.status_tx));
208        for message in &execution_result.messages {
209            log.info(message);
210        }
211
212        debug!(
213            "LLM returned {} actions for NFS {}",
214            execution_result.raw_actions.len(),
215            operation
216        );
217
218        // Return raw actions for manual processing
219        Ok(execution_result.raw_actions)
220    }
221
222    /// Answer the client in NFS's own vocabulary when the LLM backend fails.
223    ///
224    /// Fail closed, and answer. `nfsserve` turns the returned status into a well-formed reply
225    /// - MSG_ACCEPTED / SUCCESS at the RPC layer, carrying the call's own xid, with this
226    /// status as the procedure result - so the client sees a failed operation instead of
227    /// hanging until its own RPC timeout. Nothing here can fabricate a success.
228    ///
229    /// The status is deliberately *not* one of the definite ones. Returning NFS3ERR_NOENT
230    /// when the server could not even ask the model tells the client "that file does not
231    /// exist", which it will cache and act on. RFC 1813 has a status for exactly this
232    /// situation: NFS3ERR_SERVERFAULT (10006), "an error occurred on the server which does
233    /// not map to any of the legal NFS version 3 protocol error values".
234    ///
235    /// An overload is retryable and NFSv3 can say so, so `is_overload_error` is answered with
236    /// NFS3ERR_JUKEBOX (10008) - "resource temporarily unavailable, try again later" - which
237    /// is the NFS equivalent of the 503 + `Retry-After` the HTTP server sends.
238    fn llm_failure(&self, operation: &str, e: &anyhow::Error) -> nfsstat3 {
239        let log = Log::new(Some(&self.status_tx));
240        if crate::llm::is_overload_error(e) {
241            log.error(format!(
242                "NFS {}: LLM overloaded ({}) - answered NFS3ERR_JUKEBOX, client may retry",
243                operation, e
244            ));
245            nfsstat3::NFS3ERR_JUKEBOX
246        } else {
247            log.error(format!(
248                "NFS {}: LLM call failed ({}) - answered NFS3ERR_SERVERFAULT",
249                operation, e
250            ));
251            nfsstat3::NFS3ERR_SERVERFAULT
252        }
253    }
254
255    /// The model answered, but with nothing this operation can use.
256    ///
257    /// Structurally distinct from a model *rejection*: an action carrying `"error"` is the
258    /// model saying no, and still maps to the operation's own status (NFS3ERR_NOENT,
259    /// NFS3ERR_ACCES, ...). Silence is not a no, and must not be reported to the client as
260    /// one, so it lands on NFS3ERR_SERVERFAULT alongside a backend failure.
261    fn llm_no_answer(&self, operation: &str, expected: &str) -> nfsstat3 {
262        Log::new(Some(&self.status_tx)).error(format!(
263            "NFS {}: no {} action in LLM response - answered NFS3ERR_SERVERFAULT",
264            operation, expected
265        ));
266        nfsstat3::NFS3ERR_SERVERFAULT
267    }
268
269    /// Parse file type from LLM response
270    fn parse_ftype(&self, file_type: &str) -> ftype3 {
271        match file_type {
272            "regular" | "file" => ftype3::NF3REG,
273            "directory" | "dir" => ftype3::NF3DIR,
274            "symlink" | "link" => ftype3::NF3LNK,
275            "block" => ftype3::NF3BLK,
276            "char" => ftype3::NF3CHR,
277            "socket" => ftype3::NF3SOCK,
278            "fifo" => ftype3::NF3FIFO,
279            _ => ftype3::NF3REG, // Default to regular file
280        }
281    }
282
283    /// Parse NFS timestamp
284    fn parse_nfstime(&self, timestamp: Option<u64>) -> nfstime3 {
285        let ts = timestamp.unwrap_or_else(|| {
286            // unwrap_or_default rather than unwrap: a clock set before 1970 would otherwise
287            // panic inside a connection task, where the panic is silent and the server keeps
288            // reporting Running.
289            crate::utils::clock::SystemTime::now()
290                .duration_since(crate::utils::clock::UNIX_EPOCH)
291                .unwrap_or_default()
292                .as_secs()
293        });
294        nfstime3 {
295            seconds: ts as u32,
296            nseconds: 0,
297        }
298    }
299
300    /// Build fattr3 from LLM response
301    fn build_fattr3(&self, response: &serde_json::Value) -> Result<fattr3> {
302        let file_type = response
303            .get("file_type")
304            .and_then(|v| v.as_str())
305            .unwrap_or("regular");
306
307        let mode = response
308            .get("mode")
309            .and_then(|v| v.as_u64())
310            .unwrap_or(0o644) as u32;
311
312        let size = response.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
313
314        let uid = response.get("uid").and_then(|v| v.as_u64()).unwrap_or(1000) as u32;
315
316        let gid = response.get("gid").and_then(|v| v.as_u64()).unwrap_or(1000) as u32;
317
318        let atime = self.parse_nfstime(response.get("atime").and_then(|v| v.as_u64()));
319        let mtime = self.parse_nfstime(response.get("mtime").and_then(|v| v.as_u64()));
320        let ctime = self.parse_nfstime(response.get("ctime").and_then(|v| v.as_u64()));
321
322        Ok(fattr3 {
323            ftype: self.parse_ftype(file_type),
324            mode,
325            nlink: 1,
326            uid,
327            gid,
328            size,
329            used: size,
330            rdev: nfsserve::nfs::specdata3 {
331                specdata1: 0,
332                specdata2: 0,
333            },
334            fsid: 0,
335            fileid: response.get("fileid").and_then(|v| v.as_u64()).unwrap_or(0),
336            atime,
337            mtime,
338            ctime,
339        })
340    }
341}
342
343#[cfg(unix)]
344#[async_trait]
345impl NFSFileSystem for LlmNfsFileSystem {
346    fn root_dir(&self) -> fileid3 {
347        // Root directory is always fileid 1
348        1
349    }
350
351    fn capabilities(&self) -> nfsserve::vfs::VFSCapabilities {
352        // Enable all capabilities since LLM controls everything
353        nfsserve::vfs::VFSCapabilities::ReadWrite
354    }
355
356    /// Resolve a MOUNT `dirpath` to a file id, bounded at [`guard::MAX_PATH_COMPONENTS`].
357    ///
358    /// The default implementation in `nfsserve`'s `NFSFileSystem` splits the path on `/` and
359    /// calls `lookup()` once per component, with no limit. In this server every `lookup` is
360    /// one LLM round-trip, so an **unauthenticated** `MOUNTPROC3_MNT` carrying
361    /// `a/a/a/a/...` — two bytes per component — turned one forty-byte call into a sequential
362    /// chain of thousands of model calls, saturating `--llm-max-concurrent` for every other
363    /// server in the process. That is the amplification half of the `nfsserve` denial of
364    /// service; the framing half lives in [`guard`].
365    ///
366    /// Refuse, do not truncate: resolving the first 32 components of a longer path would hand
367    /// back the id of a directory the client did not ask for. `mount_handlers` turns any `Err`
368    /// into `MNT3ERR_NOENT`, so the peer gets a definite refusal on the wire and the reason is
369    /// in the log under a `decision=` tag.
370    async fn path_to_id(&self, path: &[u8]) -> Result<fileid3, nfsstat3> {
371        let components: Vec<&[u8]> = path
372            .split(|&b| b == b'/')
373            .filter(|c| !c.is_empty())
374            .collect();
375
376        if components.len() > guard::MAX_PATH_COMPONENTS {
377            Log::new(Some(&self.status_tx)).error(format!(
378                "NFS path_to_id: refused a {}-component path decision=fail_closed_path_components \
379                 (limit {}) - each component is one LLM call",
380                components.len(),
381                guard::MAX_PATH_COMPONENTS
382            ));
383            return Err(nfsstat3::NFS3ERR_NAMETOOLONG);
384        }
385
386        let mut fid = self.root_dir();
387        for component in components {
388            fid = self.lookup(fid, &component.into()).await?;
389        }
390        Ok(fid)
391    }
392
393    async fn lookup(&self, dirid: fileid3, filename: &filename3) -> Result<fileid3, nfsstat3> {
394        // Convert filename to string
395        let filename_str = String::from_utf8_lossy(filename).to_string();
396
397        let params = serde_json::json!({
398            "dirid": dirid,
399            "filename": filename_str,
400        });
401
402        // Call async LLM consultation directly
403        let result = self.consult_llm("lookup", params).await;
404
405        match result {
406            Ok(actions) => {
407                // Find nfs_lookup_response action
408                for action in actions {
409                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_lookup_response") {
410                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
411                            debug!("NFS lookup failed: {}", error);
412                            return Err(nfsstat3::NFS3ERR_NOENT);
413                        }
414
415                        if let Some(fileid) = action.get("fileid").and_then(|v| v.as_u64()) {
416                            debug!("NFS lookup found fileid: {}", fileid);
417                            return Ok(fileid);
418                        }
419                    }
420                }
421                Err(self.llm_no_answer("lookup", "nfs_lookup_response"))
422            }
423            Err(e) => Err(self.llm_failure("lookup", &e)),
424        }
425    }
426
427    async fn getattr(&self, id: fileid3) -> Result<fattr3, nfsstat3> {
428        let params = serde_json::json!({
429            "fileid": id,
430        });
431
432        let result = self.consult_llm("getattr", params).await;
433
434        match result {
435            Ok(actions) => {
436                for action in actions {
437                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_getattr_response") {
438                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
439                            debug!("NFS getattr failed: {}", error);
440                            return Err(nfsstat3::NFS3ERR_NOENT);
441                        }
442
443                        match self.build_fattr3(&action) {
444                            Ok(mut attrs) => {
445                                attrs.fileid = id; // Ensure fileid matches request
446                                debug!("NFS getattr succeeded for fileid {}", id);
447                                return Ok(attrs);
448                            }
449                            Err(e) => {
450                                error!("Failed to build fattr3: {}", e);
451                                return Err(nfsstat3::NFS3ERR_IO);
452                            }
453                        }
454                    }
455                }
456                Err(self.llm_no_answer("getattr", "nfs_getattr_response"))
457            }
458            Err(e) => Err(self.llm_failure("getattr", &e)),
459        }
460    }
461
462    async fn setattr(&self, id: fileid3, setattr: sattr3) -> Result<fattr3, nfsstat3> {
463        use nfsserve::nfs::{set_gid3, set_mode3, set_size3, set_uid3};
464
465        // Convert NFS optional enums to Option for JSON serialization
466        let mode_val = match setattr.mode {
467            set_mode3::mode(v) => Some(v as u64),
468            set_mode3::Void => None,
469        };
470        let uid_val = match setattr.uid {
471            set_uid3::uid(v) => Some(v as u64),
472            set_uid3::Void => None,
473        };
474        let gid_val = match setattr.gid {
475            set_gid3::gid(v) => Some(v as u64),
476            set_gid3::Void => None,
477        };
478        let size_val = match setattr.size {
479            set_size3::size(v) => Some(v),
480            set_size3::Void => None,
481        };
482
483        let params = serde_json::json!({
484            "fileid": id,
485            "mode": mode_val,
486            "uid": uid_val,
487            "gid": gid_val,
488            "size": size_val,
489        });
490
491        let result = self.consult_llm("setattr", params).await;
492
493        match result {
494            Ok(actions) => {
495                for action in actions {
496                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_setattr_response") {
497                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
498                            debug!("NFS setattr failed: {}", error);
499                            return Err(nfsstat3::NFS3ERR_ACCES);
500                        }
501
502                        match self.build_fattr3(&action) {
503                            Ok(mut attrs) => {
504                                attrs.fileid = id;
505                                debug!("NFS setattr succeeded for fileid {}", id);
506                                return Ok(attrs);
507                            }
508                            Err(e) => {
509                                error!("Failed to build fattr3: {}", e);
510                                return Err(nfsstat3::NFS3ERR_IO);
511                            }
512                        }
513                    }
514                }
515                Err(self.llm_no_answer("setattr", "nfs_setattr_response"))
516            }
517            Err(e) => Err(self.llm_failure("setattr", &e)),
518        }
519    }
520
521    async fn read(
522        &self,
523        id: fileid3,
524        offset: u64,
525        count: u32,
526    ) -> Result<(Vec<u8>, bool), nfsstat3> {
527        let params = serde_json::json!({
528            "fileid": id,
529            "offset": offset,
530            "count": count,
531        });
532
533        let result = self.consult_llm("read", params).await;
534
535        match result {
536            Ok(actions) => {
537                for action in actions {
538                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_read_response") {
539                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
540                            debug!("NFS read failed: {}", error);
541                            return Err(nfsstat3::NFS3ERR_ACCES);
542                        }
543
544                        // `data` is declared `required: true`. Defaulting it to "" made a
545                        // response that omitted it a *successful* read of an empty file —
546                        // the fail-open shape, since a client cannot tell that from a file
547                        // that is genuinely empty. `remove` and `rename` already refuse in
548                        // this situation; so does this now. A genuinely empty file is still
549                        // expressible as `"data": ""`, which is a decision rather than a
550                        // silence.
551                        let Some(data) = action.get("data").and_then(|v| v.as_str()) else {
552                            warn!(
553                                "NFS read: nfs_read_response for fileid {} omitted the required \
554                                 `data` field (decision=fail_closed_no_data); refusing rather \
555                                 than reporting a successful read of an empty file",
556                                id
557                            );
558                            return Err(nfsstat3::NFS3ERR_SERVERFAULT);
559                        };
560                        let data = data.as_bytes().to_vec();
561
562                        let eof = action.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
563
564                        debug!(
565                            "NFS read {} bytes from fileid {}, eof={}",
566                            data.len(),
567                            id,
568                            eof
569                        );
570                        return Ok((data, eof));
571                    }
572                }
573                Err(self.llm_no_answer("read", "nfs_read_response"))
574            }
575            Err(e) => Err(self.llm_failure("read", &e)),
576        }
577    }
578
579    async fn write(&self, id: fileid3, offset: u64, data: &[u8]) -> Result<fattr3, nfsstat3> {
580        // Lossy on purpose - actions carry no binary form. Non-UTF-8 bytes reach the model as
581        // U+FFFD and cannot be recovered; see the module docs.
582        let data_str = String::from_utf8_lossy(data).to_string();
583        let params = serde_json::json!({
584            "fileid": id,
585            "offset": offset,
586            "data": data_str,
587            "size": data.len(),
588        });
589
590        let result = self.consult_llm("write", params).await;
591
592        match result {
593            Ok(actions) => {
594                for action in actions {
595                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_write_response") {
596                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
597                            debug!("NFS write failed: {}", error);
598                            return Err(nfsstat3::NFS3ERR_ACCES);
599                        }
600
601                        match self.build_fattr3(&action) {
602                            Ok(mut attrs) => {
603                                attrs.fileid = id;
604                                debug!("NFS write {} bytes to fileid {}", data.len(), id);
605                                return Ok(attrs);
606                            }
607                            Err(e) => {
608                                error!("Failed to build fattr3: {}", e);
609                                return Err(nfsstat3::NFS3ERR_IO);
610                            }
611                        }
612                    }
613                }
614                Err(self.llm_no_answer("write", "nfs_write_response"))
615            }
616            Err(e) => Err(self.llm_failure("write", &e)),
617        }
618    }
619
620    async fn create(
621        &self,
622        dirid: fileid3,
623        filename: &filename3,
624        attr: sattr3,
625    ) -> Result<(fileid3, fattr3), nfsstat3> {
626        use nfsserve::nfs::{set_gid3, set_mode3, set_uid3};
627
628        let filename_str = String::from_utf8_lossy(filename).to_string();
629        let mode_val = match attr.mode {
630            set_mode3::mode(v) => Some(v as u64),
631            set_mode3::Void => None,
632        };
633        let uid_val = match attr.uid {
634            set_uid3::uid(v) => Some(v as u64),
635            set_uid3::Void => None,
636        };
637        let gid_val = match attr.gid {
638            set_gid3::gid(v) => Some(v as u64),
639            set_gid3::Void => None,
640        };
641
642        let params = serde_json::json!({
643            "dirid": dirid,
644            "filename": filename_str,
645            "mode": mode_val,
646            "uid": uid_val,
647            "gid": gid_val,
648        });
649
650        let result = self.consult_llm("create", params).await;
651
652        match result {
653            Ok(actions) => {
654                for action in actions {
655                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_create_response") {
656                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
657                            debug!("NFS create failed: {}", error);
658                            return Err(nfsstat3::NFS3ERR_ACCES);
659                        }
660
661                        // `fileid` is declared `required: true`. Defaulting it to 0 let a
662                        // response that omitted it report a successful CREATE carrying the
663                        // one fileid NFS treats as invalid, which a client then caches.
664                        let Some(fileid) = action.get("fileid").and_then(|v| v.as_u64()) else {
665                            warn!(
666                                "NFS create: nfs_create_response for {} omitted the required \
667                                 `fileid` (decision=fail_closed_no_fileid); refusing rather \
668                                 than reporting success with the invalid fileid 0",
669                                filename_str
670                            );
671                            return Err(nfsstat3::NFS3ERR_SERVERFAULT);
672                        };
673
674                        match self.build_fattr3(&action) {
675                            Ok(mut attrs) => {
676                                attrs.fileid = fileid;
677                                debug!(
678                                    "NFS create succeeded: {} with fileid {}",
679                                    filename_str, fileid
680                                );
681                                return Ok((fileid, attrs));
682                            }
683                            Err(e) => {
684                                error!("Failed to build fattr3: {}", e);
685                                return Err(nfsstat3::NFS3ERR_IO);
686                            }
687                        }
688                    }
689                }
690                Err(self.llm_no_answer("create", "nfs_create_response"))
691            }
692            Err(e) => Err(self.llm_failure("create", &e)),
693        }
694    }
695
696    async fn create_exclusive(
697        &self,
698        dirid: fileid3,
699        filename: &filename3,
700    ) -> Result<fileid3, nfsstat3> {
701        // Create exclusive is like create but fails if file exists
702        let filename_str = String::from_utf8_lossy(filename).to_string();
703        let params = serde_json::json!({
704            "dirid": dirid,
705            "filename": filename_str,
706            "exclusive": true,
707        });
708
709        let result = self.consult_llm("create", params).await;
710
711        match result {
712            Ok(actions) => {
713                for action in actions {
714                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_create_response") {
715                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
716                            debug!("NFS create_exclusive failed: {}", error);
717                            return Err(nfsstat3::NFS3ERR_EXIST);
718                        }
719
720                        if let Some(fileid) = action.get("fileid").and_then(|v| v.as_u64()) {
721                            debug!(
722                                "NFS create_exclusive succeeded: {} with fileid {}",
723                                filename_str, fileid
724                            );
725                            return Ok(fileid);
726                        }
727                    }
728                }
729                Err(self.llm_no_answer("create", "nfs_create_response"))
730            }
731            Err(e) => Err(self.llm_failure("create_exclusive", &e)),
732        }
733    }
734
735    async fn mkdir(
736        &self,
737        dirid: fileid3,
738        dirname: &filename3,
739    ) -> Result<(fileid3, fattr3), nfsstat3> {
740        let dirname_str = String::from_utf8_lossy(dirname).to_string();
741        let params = serde_json::json!({
742            "dirid": dirid,
743            "dirname": dirname_str,
744        });
745
746        let result = self.consult_llm("mkdir", params).await;
747
748        match result {
749            Ok(actions) => {
750                for action in actions {
751                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_mkdir_response") {
752                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
753                            debug!("NFS mkdir failed: {}", error);
754                            return Err(nfsstat3::NFS3ERR_ACCES);
755                        }
756
757                        // `nfs_mkdir_response` documents `fileid`, and that is what a model
758                        // following the action definition sends. This used to read `dirid`
759                        // only, so every documented response yielded fileid 0 and the client
760                        // got a directory it could not then look into. `dirid` is still
761                        // accepted for anything that learned the old shape.
762                        let fileid = action
763                            .get("fileid")
764                            .or_else(|| action.get("dirid"))
765                            .and_then(|v| v.as_u64())
766                            .unwrap_or(0);
767
768                        match self.build_fattr3(&action) {
769                            Ok(mut attrs) => {
770                                attrs.fileid = fileid;
771                                attrs.ftype = ftype3::NF3DIR; // Ensure it's a directory
772                                debug!(
773                                    "NFS mkdir succeeded: {} with dirid {}",
774                                    dirname_str, fileid
775                                );
776                                return Ok((fileid, attrs));
777                            }
778                            Err(e) => {
779                                error!("Failed to build fattr3: {}", e);
780                                return Err(nfsstat3::NFS3ERR_IO);
781                            }
782                        }
783                    }
784                }
785                Err(self.llm_no_answer("mkdir", "nfs_mkdir_response"))
786            }
787            Err(e) => Err(self.llm_failure("mkdir", &e)),
788        }
789    }
790
791    async fn remove(&self, dirid: fileid3, filename: &filename3) -> Result<(), nfsstat3> {
792        let filename_str = String::from_utf8_lossy(filename).to_string();
793        let params = serde_json::json!({
794            "dirid": dirid,
795            "filename": filename_str,
796        });
797
798        let result = self.consult_llm("remove", params).await;
799
800        match result {
801            Ok(actions) => {
802                for action in actions {
803                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_remove_response") {
804                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
805                            debug!("NFS remove failed: {}", error);
806                            return Err(nfsstat3::NFS3ERR_NOENT);
807                        }
808
809                        // `success` is declared `required: true` on this action and was never
810                        // read: only a non-empty `error` string produced a failure, so
811                        // `{"type": "nfs_remove_response", "success": false}` — the obvious way
812                        // for a model to refuse a delete — was reported to the client as a
813                        // completed removal. Honour it, and refuse when it is absent rather
814                        // than assuming the file went away.
815                        match action.get("success").and_then(|v| v.as_bool()) {
816                            Some(true) => {
817                                debug!("NFS remove succeeded: {}", filename_str);
818                                return Ok(());
819                            }
820                            Some(false) => {
821                                debug!(
822                                    "NFS remove refused by handler for {} \
823                                     (decision=model_reject)",
824                                    filename_str
825                                );
826                                return Err(nfsstat3::NFS3ERR_ACCES);
827                            }
828                            None => {
829                                warn!(
830                                    "NFS remove: nfs_remove_response for {} omitted the required \
831                                     `success` boolean (decision=fail_closed_no_success); \
832                                     refusing rather than assuming the file was removed",
833                                    filename_str
834                                );
835                                return Err(nfsstat3::NFS3ERR_SERVERFAULT);
836                            }
837                        }
838                    }
839                }
840                Err(self.llm_no_answer("remove", "nfs_remove_response"))
841            }
842            Err(e) => Err(self.llm_failure("remove", &e)),
843        }
844    }
845
846    async fn rename(
847        &self,
848        from_dirid: fileid3,
849        from_filename: &filename3,
850        to_dirid: fileid3,
851        to_filename: &filename3,
852    ) -> Result<(), nfsstat3> {
853        let from_name = String::from_utf8_lossy(from_filename).to_string();
854        let to_name = String::from_utf8_lossy(to_filename).to_string();
855        let params = serde_json::json!({
856            "from_dirid": from_dirid,
857            "from_filename": from_name,
858            "to_dirid": to_dirid,
859            "to_filename": to_name,
860        });
861
862        let result = self.consult_llm("rename", params).await;
863
864        match result {
865            Ok(actions) => {
866                for action in actions {
867                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_rename_response") {
868                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
869                            debug!("NFS rename failed: {}", error);
870                            return Err(nfsstat3::NFS3ERR_ACCES);
871                        }
872
873                        // Same defect as `remove`: `success` is declared required on
874                        // `nfs_rename_response` and was never read, so a model refusing with
875                        // `success: false` renamed the file anyway as far as the client knew.
876                        match action.get("success").and_then(|v| v.as_bool()) {
877                            Some(true) => {
878                                debug!("NFS rename succeeded: {} -> {}", from_name, to_name);
879                                return Ok(());
880                            }
881                            Some(false) => {
882                                debug!(
883                                    "NFS rename refused by handler for {} -> {} \
884                                     (decision=model_reject)",
885                                    from_name, to_name
886                                );
887                                return Err(nfsstat3::NFS3ERR_ACCES);
888                            }
889                            None => {
890                                warn!(
891                                    "NFS rename: nfs_rename_response for {} -> {} omitted the \
892                                     required `success` boolean \
893                                     (decision=fail_closed_no_success); refusing rather than \
894                                     assuming the rename happened",
895                                    from_name, to_name
896                                );
897                                return Err(nfsstat3::NFS3ERR_SERVERFAULT);
898                            }
899                        }
900                    }
901                }
902                Err(self.llm_no_answer("rename", "nfs_rename_response"))
903            }
904            Err(e) => Err(self.llm_failure("rename", &e)),
905        }
906    }
907
908    async fn readdir(
909        &self,
910        dirid: fileid3,
911        start_after: fileid3,
912        max_entries: usize,
913    ) -> Result<ReadDirResult, nfsstat3> {
914        let params = serde_json::json!({
915            "dirid": dirid,
916            "start_after": start_after,
917            "max_entries": max_entries,
918        });
919
920        let result = self.consult_llm("readdir", params).await;
921
922        match result {
923            Ok(actions) => {
924                for action in actions {
925                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_readdir_response") {
926                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
927                            debug!("NFS readdir failed: {}", error);
928                            return Err(nfsstat3::NFS3ERR_NOTDIR);
929                        }
930
931                        // `entries` is declared `required: true`. Defaulting it to an empty
932                        // array made a response that omitted it a successful enumeration of
933                        // an empty directory — a positive assertion the handler never made.
934                        // An actually-empty directory is still expressible as
935                        // `"entries": []`.
936                        let Some(entries_json) =
937                            action.get("entries").and_then(|v| v.as_array()).cloned()
938                        else {
939                            warn!(
940                                "NFS readdir: nfs_readdir_response omitted the required \
941                                 `entries` array (decision=fail_closed_no_entries); refusing \
942                                 rather than reporting the directory as empty"
943                            );
944                            return Err(nfsstat3::NFS3ERR_SERVERFAULT);
945                        };
946
947                        let mut entries = Vec::new();
948                        for entry in entries_json {
949                            let fileid = entry.get("fileid").and_then(|v| v.as_u64()).unwrap_or(0);
950
951                            let name = entry
952                                .get("name")
953                                .and_then(|v| v.as_str())
954                                .unwrap_or("")
955                                .as_bytes()
956                                .to_vec();
957
958                            // Build attributes, use defaults if LLM didn't provide them
959                            let mut attr =
960                                match entry.get("attr").and_then(|v| self.build_fattr3(v).ok()) {
961                                    Some(a) => a,
962                                    None => {
963                                        // Provide minimal default attributes
964                                        fattr3 {
965                                            ftype: ftype3::NF3REG,
966                                            mode: 0o644,
967                                            nlink: 1,
968                                            uid: 1000,
969                                            gid: 1000,
970                                            size: 0,
971                                            used: 0,
972                                            rdev: nfsserve::nfs::specdata3 {
973                                                specdata1: 0,
974                                                specdata2: 0,
975                                            },
976                                            fsid: 0,
977                                            fileid,
978                                            atime: self.parse_nfstime(None),
979                                            mtime: self.parse_nfstime(None),
980                                            ctime: self.parse_nfstime(None),
981                                        }
982                                    }
983                                };
984                            attr.fileid = fileid; // Ensure fileid matches
985
986                            entries.push(DirEntry {
987                                fileid,
988                                name: nfsserve::nfs::nfsstring(name),
989                                attr,
990                            });
991                        }
992
993                        // `nfs_readdir_response` documents `eof`; this used to read `end`
994                        // only, so a model following the action definition always got the
995                        // default. Both are accepted, documented name first.
996                        let end = action
997                            .get("eof")
998                            .or_else(|| action.get("end"))
999                            .and_then(|v| v.as_bool())
1000                            .unwrap_or(true);
1001
1002                        debug!(
1003                            "NFS readdir returned {} entries, end={}",
1004                            entries.len(),
1005                            end
1006                        );
1007                        return Ok(ReadDirResult { entries, end });
1008                    }
1009                }
1010                Err(self.llm_no_answer("readdir", "nfs_readdir_response"))
1011            }
1012            Err(e) => Err(self.llm_failure("readdir", &e)),
1013        }
1014    }
1015
1016    async fn symlink(
1017        &self,
1018        dirid: fileid3,
1019        linkname: &filename3,
1020        symlink: &nfspath3,
1021        attr: &sattr3,
1022    ) -> Result<(fileid3, fattr3), nfsstat3> {
1023        use nfsserve::nfs::set_mode3;
1024
1025        let linkname_str = String::from_utf8_lossy(linkname).to_string();
1026        let target_str = String::from_utf8_lossy(symlink).to_string();
1027        let mode_val = match attr.mode {
1028            set_mode3::mode(v) => Some(v as u64),
1029            set_mode3::Void => None,
1030        };
1031
1032        let params = serde_json::json!({
1033            "dirid": dirid,
1034            "linkname": linkname_str,
1035            "target": target_str,
1036            "mode": mode_val,
1037        });
1038
1039        let result = self.consult_llm("symlink", params).await;
1040
1041        match result {
1042            Ok(actions) => {
1043                for action in actions {
1044                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_create_response") {
1045                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
1046                            debug!("NFS symlink failed: {}", error);
1047                            return Err(nfsstat3::NFS3ERR_ACCES);
1048                        }
1049
1050                        let fileid = action.get("fileid").and_then(|v| v.as_u64()).unwrap_or(0);
1051
1052                        match self.build_fattr3(&action) {
1053                            Ok(mut attrs) => {
1054                                attrs.fileid = fileid;
1055                                attrs.ftype = ftype3::NF3LNK; // Ensure it's a symlink
1056                                debug!("NFS symlink succeeded: {} -> {}", linkname_str, target_str);
1057                                return Ok((fileid, attrs));
1058                            }
1059                            Err(e) => {
1060                                error!("Failed to build fattr3: {}", e);
1061                                return Err(nfsstat3::NFS3ERR_IO);
1062                            }
1063                        }
1064                    }
1065                }
1066                Err(self.llm_no_answer("symlink", "nfs_create_response"))
1067            }
1068            Err(e) => Err(self.llm_failure("symlink", &e)),
1069        }
1070    }
1071
1072    async fn readlink(&self, id: fileid3) -> Result<nfspath3, nfsstat3> {
1073        let params = serde_json::json!({
1074            "fileid": id,
1075        });
1076
1077        let result = self.consult_llm("readlink", params).await;
1078
1079        match result {
1080            Ok(actions) => {
1081                for action in actions {
1082                    // Reuse nfs_read_response for readlink (returns target path in data field)
1083                    if action.get("type").and_then(|v| v.as_str()) == Some("nfs_read_response") {
1084                        if let Some(error) = action.get("error").and_then(|v| v.as_str()) {
1085                            debug!("NFS readlink failed: {}", error);
1086                            return Err(nfsstat3::NFS3ERR_INVAL);
1087                        }
1088
1089                        let target_bytes = action
1090                            .get("data")
1091                            .and_then(|v| v.as_str())
1092                            .unwrap_or("")
1093                            .as_bytes()
1094                            .to_vec();
1095
1096                        debug!(
1097                            "NFS readlink for fileid {}: {}",
1098                            id,
1099                            String::from_utf8_lossy(&target_bytes)
1100                        );
1101                        return Ok(nfsserve::nfs::nfsstring(target_bytes));
1102                    }
1103                }
1104                Err(self.llm_no_answer("readlink", "nfs_read_response"))
1105            }
1106            Err(e) => Err(self.llm_failure("readlink", &e)),
1107        }
1108    }
1109}