Skip to main content

netget/client/nfs/
mod.rs

1//! NFS client implementation
2pub mod actions;
3
4pub use actions::NfsClientProtocol;
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::{mpsc, Mutex};
11use tracing::{error, info, trace, warn};
12
13use crate::client::llm_budget::call_llm_for_client;
14use crate::llm::actions::client_trait::{Client as ClientTrait, ClientActionResult};
15use crate::llm::ollama_client::OllamaClient;
16use crate::logging::emit::Log;
17use crate::protocol::Event;
18use crate::state::app_state::AppState;
19use crate::state::{ClientId, ClientStatus};
20use serde_json::json;
21
22#[cfg(unix)]
23use nfs3_client::{tokio::TokioConnector, Nfs3ConnectionBuilder};
24#[cfg(unix)]
25use nfs3_types::nfs3::*;
26
27use crate::client::nfs::actions::{NFS_CLIENT_CONNECTED_EVENT, NFS_CLIENT_OPERATION_RESULT_EVENT};
28
29/// The mounted NFS3 connection. Named because it appears in a dozen signatures.
30#[cfg(unix)]
31type NfsConn = nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>;
32
33/// NFS client that connects to a remote NFS server
34pub struct NfsClient;
35
36impl NfsClient {
37    /// Connect to an NFS server with integrated LLM actions
38    #[cfg(unix)]
39    pub async fn connect_with_llm_actions(
40        remote_addr: String,
41        llm_client: OllamaClient,
42        app_state: Arc<AppState>,
43        status_tx: mpsc::UnboundedSender<String>,
44        client_id: ClientId,
45        startup_params: Option<crate::protocol::StartupParams>,
46    ) -> Result<SocketAddr> {
47        // Parse remote_addr into server and export path
48        // Format: server:port:/export/path or server:/export/path (default port 2049)
49        let (server_addr, export_path) = Self::parse_nfs_address(&remote_addr)?;
50
51        Log::new(Some(&status_tx)).info(format!(
52            "NFS client {} attempting to connect to {} for export {}",
53            client_id, server_addr, export_path
54        ));
55
56        // Extract just the server part (remove port if present)
57        let server = server_addr.split(':').next().unwrap_or(&server_addr);
58
59        // Where to find the three RPC programs. The defaults are the real-world ones —
60        // portmapper on 111, MOUNT and NFS resolved through it, and a privileged local source
61        // port, which many servers demand. They are overridable because a server that
62        // multiplexes all three programs on one ephemeral port (NetGet's own does, through
63        // `nfsserve`) is otherwise unreachable: without an override the client asks 111 for
64        // ports no matter what address it was given.
65        let params = startup_params.as_ref();
66        let port_param = |key: &str| -> Result<Option<u16>> {
67            let raw = params
68                .map(|p| p.get_optional_u64(key))
69                .transpose()?
70                .flatten();
71            match raw {
72                None => Ok(None),
73                Some(v) if (1..=65535).contains(&v) => Ok(Some(v as u16)),
74                Some(v) => Err(anyhow::anyhow!(
75                    "NFS client '{key}' must be between 1 and 65535, got {v}"
76                )),
77            }
78        };
79        let portmapper_port = port_param("portmapper_port")?;
80        let mount_port = port_param("mount_port")?;
81        let nfs_port = port_param("nfs_port")?;
82        let privileged_source_port = params
83            .map(|p| p.get_optional_bool("privileged_source_port"))
84            .transpose()?
85            .flatten()
86            .unwrap_or(true);
87
88        // Mount the NFS export
89        let mut builder = Nfs3ConnectionBuilder::new(TokioConnector, server, &export_path)
90            .connect_from_privileged_port(privileged_source_port);
91        if let Some(port) = portmapper_port {
92            builder = builder.portmapper_port(port);
93        }
94        if let Some(port) = mount_port {
95            builder = builder.mount_port(port);
96        }
97        if let Some(port) = nfs_port {
98            builder = builder.nfs3_port(port);
99        }
100        let connection = builder
101            .mount()
102            .await
103            .context("Failed to mount NFS export")?;
104
105        Log::new(Some(&status_tx)).info(format!(
106            "NFS client {} successfully mounted export {}",
107            client_id, export_path
108        ));
109
110        // Get root file handle
111        let root_fh = connection.root_nfs_fh3();
112        let root_fh_hex = hex::encode(&root_fh.data.0);
113
114        // Update client status to connected
115        app_state
116            .update_client_status(client_id, ClientStatus::Connected)
117            .await;
118        let _ = status_tx.send("__UPDATE_UI__".to_string());
119
120        // Create file handle cache
121        let fh_cache = Arc::new(Mutex::new(HashMap::new()));
122        fh_cache
123            .lock()
124            .await
125            .insert("/".to_string(), root_fh.clone());
126
127        // Spawn NFS operation handler
128        let connection_arc = Arc::new(Mutex::new(connection));
129
130        // The dashboard's `[ send ]` channel, registered BEFORE the connected-event LLM call
131        // that the handler task below makes. A dashboard-created client defaults to a
132        // `*` -> manual rule, so that call can park for minutes waiting for a human;
133        // registering after it would leave the rail reading "no command channel" for the
134        // whole park.
135        let command_rx =
136            crate::client::command_support::register_command_channel(&app_state, client_id).await;
137        let command_task = tokio::spawn(Self::command_loop(
138            connection_arc.clone(),
139            fh_cache.clone(),
140            command_rx,
141            client_id,
142            llm_client.clone(),
143            app_state.clone(),
144            status_tx.clone(),
145        ));
146        app_state
147            .register_client_task(client_id, command_task)
148            .await;
149
150        let app_state_clone = app_state.clone();
151        let llm_client_clone = llm_client.clone();
152        let status_tx_clone = status_tx.clone();
153        let fh_cache_clone = fh_cache.clone();
154
155        // Registered with AppState so stop_client can abort this task —
156        // dropping a JoinHandle only detaches it in Tokio.
157        let task_registrar = app_state.clone();
158        let task_handle = tokio::spawn(async move {
159            if let Err(e) = Self::handle_nfs_operations(
160                connection_arc,
161                fh_cache_clone,
162                client_id,
163                export_path,
164                root_fh_hex,
165                llm_client_clone,
166                app_state_clone,
167                status_tx_clone,
168            )
169            .await
170            {
171                error!("NFS client {} handler error: {}", client_id, e);
172            }
173        });
174        task_registrar
175            .register_client_task(client_id, task_handle)
176            .await;
177
178        // The address the dashboard shows for this client.
179        //
180        // This used to be `format!("{}:2049", server).parse()?`, which is wrong twice. The
181        // `?` runs *after* the export is mounted and after both tasks are spawned and
182        // registered, so for any non-literal-IP `server` — `fileserver.local:/home`, which
183        // the docs give as an example — `connect()` returned `Err` on a connection that had
184        // in fact succeeded and was still running: the client was reported as failed while
185        // its mount and its two tasks stayed alive. And when it did parse, the port was
186        // hardcoded 2049 regardless of `nfs_port`, so the rail named a port the client had
187        // never spoken to.
188        //
189        // `nfs3_client` only dials a literal IP (`Nfs3ConnectionBuilder` parses `server` as
190        // an `IpAddr`), so if the mount above succeeded then `server` parses here too. Fall
191        // back to an unspecified address rather than failing a live connection.
192        let shown_port = nfs_port.unwrap_or(2049);
193        let shown_addr = server
194            .parse::<std::net::IpAddr>()
195            .map(|ip| SocketAddr::new(ip, shown_port))
196            .unwrap_or_else(|_| {
197                SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
198            });
199        Ok(shown_addr)
200    }
201
202    /// Handle NFS operations with LLM integration
203    #[cfg(unix)]
204    async fn handle_nfs_operations(
205        connection: Arc<Mutex<NfsConn>>,
206        fh_cache: Arc<Mutex<HashMap<String, nfs_fh3>>>,
207        client_id: ClientId,
208        export_path: String,
209        root_fh_hex: String,
210        llm_client: OllamaClient,
211        app_state: Arc<AppState>,
212        status_tx: mpsc::UnboundedSender<String>,
213    ) -> Result<()> {
214        // Get instruction
215        let instruction = app_state
216            .get_instruction_for_client(client_id)
217            .await
218            .unwrap_or_default();
219
220        let protocol = Arc::new(NfsClientProtocol::new());
221
222        // Send connected event to LLM
223        let connected_event = Event::new(
224            &NFS_CLIENT_CONNECTED_EVENT,
225            json!({
226                "export_path": export_path,
227                "root_fh": root_fh_hex
228            }),
229        );
230
231        let memory = app_state
232            .get_memory_for_client(client_id)
233            .await
234            .unwrap_or_default();
235
236        let result = call_llm_for_client(
237            &llm_client,
238            &app_state,
239            client_id.to_string(),
240            &instruction,
241            &memory,
242            Some(&connected_event),
243            protocol.as_ref(),
244            &status_tx,
245        )
246        .await?;
247
248        // Update memory
249        if let Some(new_memory) = result.memory_updates {
250            app_state.set_memory_for_client(client_id, new_memory).await;
251        }
252
253        // Execute actions from LLM
254        for action in result.actions {
255            if let Err(e) = Self::execute_action(
256                &connection,
257                &fh_cache,
258                action,
259                client_id,
260                &llm_client,
261                &app_state,
262                &status_tx,
263                protocol.as_ref(),
264            )
265            .await
266            {
267                Log::new(Some(&status_tx)).error(format!("NFS operation failed: {}", e));
268            }
269        }
270
271        // Keep client alive - monitor for disconnection
272        loop {
273            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
274
275            if app_state.get_client(client_id).await.is_none() {
276                info!("NFS client {} stopped", client_id);
277                break;
278            }
279        }
280
281        Ok(())
282    }
283
284    /// Execute a single NFS action
285    #[cfg(unix)]
286    async fn execute_action(
287        connection: &Arc<Mutex<NfsConn>>,
288        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
289        action: serde_json::Value,
290        client_id: ClientId,
291        llm_client: &OllamaClient,
292        app_state: &Arc<AppState>,
293        status_tx: &mpsc::UnboundedSender<String>,
294        protocol: &dyn ClientTrait,
295    ) -> Result<()> {
296        let action_result = protocol.execute_action(action.clone())?;
297
298        match action_result {
299            ClientActionResult::Disconnect => {
300                info!("NFS client {} disconnecting", client_id);
301                // Stop offering [ send ] on a session that is going away.
302                app_state.remove_client_handle(client_id).await;
303                app_state
304                    .update_client_status(client_id, ClientStatus::Disconnected)
305                    .await;
306                return Ok(());
307            }
308            ClientActionResult::WaitForMore => {
309                trace!("NFS client {} waiting", client_id);
310                return Ok(());
311            }
312            ClientActionResult::Custom { name, data } => {
313                // A `?` here used to short-circuit past `report_operation`, so every NFS
314                // error status — `Read failed: NFS3ERR_ACCES`, a lookup on a path that does
315                // not exist — died as a log line and raised no event. The model that asked
316                // for the operation got silence and could neither retry nor adapt: the
317                // action chain simply stopped. That is the "asks the model what to do and
318                // throws the answer away" shape from the root CLAUDE.md, one step further
319                // along. The failure is now an event like any other result.
320                let result_data = match Self::perform_op(connection, fh_cache, &name, data).await {
321                    Ok(result_data) => {
322                        info!("NFS client {} completed operation: {}", client_id, name);
323                        result_data
324                    }
325                    Err(e) => {
326                        warn!("NFS client {} operation {} failed: {}", client_id, name, e);
327                        serde_json::json!({
328                            "success": false,
329                            "error": e.to_string(),
330                        })
331                    }
332                };
333
334                Box::pin(Self::report_operation(
335                    connection,
336                    fh_cache,
337                    &name,
338                    result_data,
339                    client_id,
340                    llm_client,
341                    app_state,
342                    status_tx,
343                ))
344                .await?;
345            }
346            _ => {}
347        }
348
349        Ok(())
350    }
351
352    /// Raise `nfs_operation_result` for a completed operation and execute whatever the
353    /// handler answers.
354    ///
355    /// Split out of [`Self::execute_action`] so the command loop can run an injected
356    /// operation, reply with its outcome immediately, and only then let the model react - a
357    /// handler parked for a human must not hold up the next injected command.
358    #[cfg(unix)]
359    #[allow(clippy::too_many_arguments)]
360    async fn report_operation(
361        connection: &Arc<Mutex<NfsConn>>,
362        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
363        name: &str,
364        result_data: serde_json::Value,
365        client_id: ClientId,
366        llm_client: &OllamaClient,
367        app_state: &Arc<AppState>,
368        status_tx: &mpsc::UnboundedSender<String>,
369    ) -> Result<()> {
370        let protocol = NfsClientProtocol::new();
371
372        let result_event = Event::new(
373            &NFS_CLIENT_OPERATION_RESULT_EVENT,
374            json!({
375                "operation": name,
376                "result": result_data
377            }),
378        );
379
380        let instruction = app_state
381            .get_instruction_for_client(client_id)
382            .await
383            .unwrap_or_default();
384        let memory = app_state
385            .get_memory_for_client(client_id)
386            .await
387            .unwrap_or_default();
388
389        let llm_result = call_llm_for_client(
390            llm_client,
391            app_state,
392            client_id.to_string(),
393            &instruction,
394            &memory,
395            Some(&result_event),
396            &protocol,
397            status_tx,
398        )
399        .await?;
400
401        if let Some(new_memory) = llm_result.memory_updates {
402            app_state.set_memory_for_client(client_id, new_memory).await;
403        }
404
405        for next_action in llm_result.actions {
406            Box::pin(Self::execute_action(
407                connection,
408                fh_cache,
409                next_action,
410                client_id,
411                llm_client,
412                app_state,
413                status_tx,
414                &protocol,
415            ))
416            .await?;
417        }
418        Ok(())
419    }
420
421    /// Run one NFS operation against the mounted export.
422    ///
423    /// The single place the operation table lives, so an injected dashboard command and an
424    /// LLM-produced action reach exactly the same code.
425    #[cfg(unix)]
426    async fn perform_op(
427        connection: &Arc<Mutex<NfsConn>>,
428        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
429        name: &str,
430        data: serde_json::Value,
431    ) -> Result<serde_json::Value> {
432        match name {
433            "nfs_lookup" => Self::op_lookup(connection, fh_cache, data).await,
434            "nfs_read_file" => Self::op_read_file(connection, fh_cache, data).await,
435            "nfs_write_file" => Self::op_write_file(connection, fh_cache, data).await,
436            "nfs_list_dir" => Self::op_list_dir(connection, fh_cache, data).await,
437            "nfs_get_attr" => Self::op_get_attr(connection, fh_cache, data).await,
438            "nfs_create_file" => Self::op_create_file(connection, fh_cache, data).await,
439            "nfs_mkdir" => Self::op_mkdir(connection, fh_cache, data).await,
440            "nfs_remove" => Self::op_remove(connection, fh_cache, data).await,
441            "nfs_rmdir" => Self::op_rmdir(connection, fh_cache, data).await,
442            other => Err(anyhow::anyhow!("Unknown NFS operation: {}", other)),
443        }
444    }
445
446    /// Drain injected commands (the dashboard's `[ send ]`) until the channel closes - which
447    /// happens when the client is removed - or an injected `disconnect` ends the session.
448    ///
449    /// A task of its own rather than a `select!` arm: the ONC-RPC exchanges inside
450    /// `nfs3_client` read with `read_exact`, which is not cancellation-safe, and the
451    /// connection mutex is what serialises this loop against the handler task.
452    ///
453    /// Outcome semantics: `nfs3_client` frames every RPC, so this loop can honestly claim no
454    /// byte count. An operation that ran reports `Executed` naming it and summarising the
455    /// reply; one the server refused is an `Err`, never a quieter `Sent`.
456    #[cfg(unix)]
457    #[allow(clippy::too_many_arguments)]
458    async fn command_loop(
459        connection: Arc<Mutex<NfsConn>>,
460        fh_cache: Arc<Mutex<HashMap<String, nfs_fh3>>>,
461        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
462        client_id: ClientId,
463        llm_client: OllamaClient,
464        app_state: Arc<AppState>,
465        status_tx: mpsc::UnboundedSender<String>,
466    ) {
467        use crate::llm::actions::protocol_trait::Protocol;
468        use crate::state::client_handles::ClientSendOutcome;
469        use crate::state::AccessLogOwner;
470
471        let protocol = Arc::new(NfsClientProtocol::new());
472
473        while let Some(command) = command_rx.recv().await {
474            let action = command.action.clone();
475            let mut follow_up: Option<(String, serde_json::Value)> = None;
476            let mut disconnect = false;
477
478            let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
479                Err(e) => Ok(ClientSendOutcome::Rejected {
480                    error: e.to_string(),
481                }),
482                Ok(ClientActionResult::Disconnect) => {
483                    disconnect = true;
484                    Ok(ClientSendOutcome::Disconnected)
485                }
486                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
487                    detail: "wait_for_more: nothing was asked of the export".to_string(),
488                }),
489                Ok(ClientActionResult::NoAction) => Ok(ClientSendOutcome::Executed {
490                    detail: "no_action".to_string(),
491                }),
492                Ok(ClientActionResult::Custom { name, data }) => {
493                    match Self::perform_op(&connection, &fh_cache, &name, data).await {
494                        Ok(result) => {
495                            let detail = format!("{name} completed: {result}");
496                            follow_up = Some((name, result));
497                            Ok(ClientSendOutcome::Executed { detail })
498                        }
499                        Err(e) => Err(e.context(format!("injected NFS action '{name}'"))),
500                    }
501                }
502                Ok(other) => Ok(ClientSendOutcome::Executed {
503                    detail: format!("unsupported action result {other:?}"),
504                }),
505            };
506
507            let outcome_json = match &outcome {
508                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
509                Err(e) => serde_json::json!({"error": e.to_string()}),
510            };
511            app_state
512                .record_access_log(
513                    AccessLogOwner::Client(client_id.as_u32()),
514                    protocol.protocol_name(),
515                    None,
516                    "injected_action",
517                    action,
518                    vec![outcome_json],
519                )
520                .await;
521
522            if let Err(e) = &outcome {
523                error!("NFS client {} injected action failed: {}", client_id, e);
524                let _ = status_tx.send(format!(
525                    "[WARN] Client {} injected action failed: {}",
526                    client_id, e
527                ));
528            }
529            let _ = status_tx.send("__UPDATE_UI__".to_string());
530            crate::client::command_support::reply(command, outcome);
531
532            if disconnect {
533                app_state
534                    .update_client_status(client_id, ClientStatus::Disconnected)
535                    .await;
536                break;
537            }
538
539            // The operation-result event goes to the model in its own task: a handler parked
540            // for a human must not block the next injected command.
541            if let Some((name, result)) = follow_up {
542                let connection = connection.clone();
543                let fh_cache = fh_cache.clone();
544                let llm_client = llm_client.clone();
545                let state = app_state.clone();
546                let tx = status_tx.clone();
547                let handle = tokio::spawn(async move {
548                    if let Err(e) = Self::report_operation(
549                        &connection,
550                        &fh_cache,
551                        &name,
552                        result,
553                        client_id,
554                        &llm_client,
555                        &state,
556                        &tx,
557                    )
558                    .await
559                    {
560                        error!("NFS client {} follow-up failed: {}", client_id, e);
561                    }
562                });
563                app_state.register_client_task(client_id, handle).await;
564            }
565        }
566
567        // Every exit lands here: drop the handle so the rail stops offering [ send ] on a
568        // session nothing is draining any more.
569        app_state.remove_client_handle(client_id).await;
570        let _ = status_tx.send("__UPDATE_UI__".to_string());
571    }
572
573    /// Resolve path to file handle
574    #[cfg(unix)]
575    async fn resolve_path(
576        connection: &Arc<Mutex<NfsConn>>,
577        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
578        path: &str,
579    ) -> Result<nfs_fh3> {
580        // Check cache
581        {
582            let cache = fh_cache.lock().await;
583            if let Some(fh) = cache.get(path) {
584                return Ok(fh.clone());
585            }
586        }
587
588        // Root directory
589        let components: Vec<&str> = path
590            .trim_start_matches('/')
591            .split('/')
592            .filter(|s| !s.is_empty())
593            .collect();
594
595        if components.is_empty() {
596            let conn = connection.lock().await;
597            return Ok(conn.root_nfs_fh3());
598        }
599
600        // Walk path
601        let mut current_fh = {
602            let conn = connection.lock().await;
603            conn.root_nfs_fh3()
604        };
605        let mut current_path = String::from("/");
606
607        for component in components {
608            let lookup_args = LOOKUP3args {
609                what: diropargs3 {
610                    dir: current_fh.clone(),
611                    name: component.as_bytes().into(),
612                },
613            };
614
615            let lookup_res = connection.lock().await.lookup(&lookup_args).await?;
616
617            match lookup_res {
618                LOOKUP3res::Ok(ok_res) => {
619                    current_fh = ok_res.object;
620                    current_path = if current_path == "/" {
621                        format!("/{}", component)
622                    } else {
623                        format!("{}/{}", current_path, component)
624                    };
625                    fh_cache
626                        .lock()
627                        .await
628                        .insert(current_path.clone(), current_fh.clone());
629                }
630                LOOKUP3res::Err((stat, _)) => {
631                    return Err(anyhow::anyhow!(
632                        "Lookup failed for {}: {:?}",
633                        component,
634                        stat
635                    ));
636                }
637            }
638        }
639
640        Ok(current_fh)
641    }
642
643    /// NFS lookup operation
644    #[cfg(unix)]
645    async fn op_lookup(
646        connection: &Arc<Mutex<NfsConn>>,
647        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
648        data: serde_json::Value,
649    ) -> Result<serde_json::Value> {
650        let path = data["path"].as_str().context("Missing 'path' parameter")?;
651        let fh = Self::resolve_path(connection, fh_cache, path).await?;
652
653        Ok(json!({
654            "path": path,
655            "file_handle": hex::encode(&fh.data.0),
656            "success": true
657        }))
658    }
659
660    /// NFS read file operation
661    #[cfg(unix)]
662    async fn op_read_file(
663        connection: &Arc<Mutex<NfsConn>>,
664        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
665        data: serde_json::Value,
666    ) -> Result<serde_json::Value> {
667        let path = data["path"].as_str().context("Missing 'path' parameter")?;
668        let offset = data["offset"].as_u64().unwrap_or(0);
669        let count = crate::client::wire_values::number::<u32>(&data, "count", 4096)?;
670
671        let fh = Self::resolve_path(connection, fh_cache, path).await?;
672
673        let read_args = READ3args {
674            file: fh,
675            offset,
676            count,
677        };
678
679        let read_res = connection.lock().await.read(&read_args).await?;
680
681        match read_res {
682            READ3res::Ok(ok_res) => {
683                let data_str = String::from_utf8_lossy(&ok_res.data.0).to_string();
684                Ok(json!({
685                    "path": path,
686                    "data": data_str,
687                    "bytes_read": ok_res.count,
688                    "eof": ok_res.eof,
689                    "success": true
690                }))
691            }
692            READ3res::Err((stat, _)) => Err(anyhow::anyhow!("Read failed: {:?}", stat)),
693        }
694    }
695
696    /// NFS write file operation
697    #[cfg(unix)]
698    async fn op_write_file(
699        connection: &Arc<Mutex<NfsConn>>,
700        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
701        data: serde_json::Value,
702    ) -> Result<serde_json::Value> {
703        let path = data["path"].as_str().context("Missing 'path' parameter")?;
704        let write_data = data["data"].as_str().context("Missing 'data' parameter")?;
705        let offset = data["offset"].as_u64().unwrap_or(0);
706
707        let fh = Self::resolve_path(connection, fh_cache, path).await?;
708
709        let write_args = WRITE3args {
710            file: fh,
711            offset,
712            count: u32::try_from(write_data.len()).context("NFS write exceeds u32 length")?,
713            stable: stable_how::FILE_SYNC,
714            data: write_data.as_bytes().into(),
715        };
716
717        let write_res = connection.lock().await.write(&write_args).await?;
718
719        match write_res {
720            WRITE3res::Ok(ok_res) => Ok(json!({
721                "path": path,
722                "bytes_written": ok_res.count,
723                "success": true
724            })),
725            WRITE3res::Err((stat, _)) => Err(anyhow::anyhow!("Write failed: {:?}", stat)),
726        }
727    }
728
729    /// NFS list directory operation
730    #[cfg(unix)]
731    async fn op_list_dir(
732        connection: &Arc<Mutex<NfsConn>>,
733        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
734        data: serde_json::Value,
735    ) -> Result<serde_json::Value> {
736        let path = data["path"].as_str().unwrap_or("/");
737        let fh = Self::resolve_path(connection, fh_cache, path).await?;
738
739        let readdir_args = READDIR3args {
740            dir: fh,
741            cookie: 0,
742            cookieverf: cookieverf3([0; 8]),
743            count: 4096,
744        };
745
746        let readdir_res = connection.lock().await.readdir(&readdir_args).await?;
747
748        match readdir_res {
749            READDIR3res::Ok(ok_res) => {
750                let mut entries = Vec::new();
751
752                // List<T> is a wrapper around Vec<T>, access via .0
753                for entry in &ok_res.reply.entries.0 {
754                    entries.push(json!({
755                        "name": String::from_utf8_lossy(entry.name.as_ref()),
756                        "fileid": entry.fileid
757                    }));
758                }
759
760                Ok(json!({
761                    "path": path,
762                    "entries": entries,
763                    "success": true
764                }))
765            }
766            READDIR3res::Err((stat, _)) => Err(anyhow::anyhow!("Readdir failed: {:?}", stat)),
767        }
768    }
769
770    /// NFS get attributes operation
771    #[cfg(unix)]
772    async fn op_get_attr(
773        connection: &Arc<Mutex<NfsConn>>,
774        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
775        data: serde_json::Value,
776    ) -> Result<serde_json::Value> {
777        let path = data["path"].as_str().context("Missing 'path' parameter")?;
778        let fh = Self::resolve_path(connection, fh_cache, path).await?;
779
780        let getattr_args = GETATTR3args { object: fh };
781
782        let getattr_res = connection.lock().await.getattr(&getattr_args).await?;
783
784        match getattr_res {
785            GETATTR3res::Ok(ok_res) => Ok(json!({
786                "path": path,
787                "type": format!("{:?}", ok_res.obj_attributes.type_),
788                "size": ok_res.obj_attributes.size,
789                "mode": ok_res.obj_attributes.mode,
790                "success": true
791            })),
792            GETATTR3res::Err(stat) => Err(anyhow::anyhow!("Getattr failed: {:?}", stat)),
793        }
794    }
795
796    /// NFS create file operation
797    #[cfg(unix)]
798    async fn op_create_file(
799        connection: &Arc<Mutex<NfsConn>>,
800        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
801        data: serde_json::Value,
802    ) -> Result<serde_json::Value> {
803        let path = data["path"].as_str().context("Missing 'path' parameter")?;
804        let mode = crate::client::wire_values::number::<u32>(&data, "mode", 0o644)?;
805
806        let (dir_path, filename) = path.rsplit_once('/').unwrap_or(("/", path));
807        let dir_fh = Self::resolve_path(connection, fh_cache, dir_path).await?;
808
809        let create_args = CREATE3args {
810            where_: diropargs3 {
811                dir: dir_fh,
812                name: filename.as_bytes().into(),
813            },
814            how: createhow3::UNCHECKED(sattr3 {
815                mode: Nfs3Option::Some(mode),
816                uid: Nfs3Option::None,
817                gid: Nfs3Option::None,
818                size: Nfs3Option::None,
819                atime: set_atime::DONT_CHANGE,
820                mtime: set_mtime::DONT_CHANGE,
821            }),
822        };
823
824        let create_res = connection.lock().await.create(&create_args).await?;
825
826        match create_res {
827            CREATE3res::Ok(ok_res) => {
828                if let Nfs3Option::Some(obj) = ok_res.obj {
829                    fh_cache.lock().await.insert(path.to_string(), obj);
830                }
831                Ok(json!({
832                    "path": path,
833                    "created": true,
834                    "success": true
835                }))
836            }
837            CREATE3res::Err((stat, _)) => Err(anyhow::anyhow!("Create failed: {:?}", stat)),
838        }
839    }
840
841    /// NFS mkdir operation
842    #[cfg(unix)]
843    async fn op_mkdir(
844        connection: &Arc<Mutex<NfsConn>>,
845        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
846        data: serde_json::Value,
847    ) -> Result<serde_json::Value> {
848        let path = data["path"].as_str().context("Missing 'path' parameter")?;
849        let mode = crate::client::wire_values::number::<u32>(&data, "mode", 0o755)?;
850
851        let (dir_path, dirname) = path.rsplit_once('/').unwrap_or(("/", path));
852        let dir_fh = Self::resolve_path(connection, fh_cache, dir_path).await?;
853
854        let mkdir_args = MKDIR3args {
855            where_: diropargs3 {
856                dir: dir_fh,
857                name: dirname.as_bytes().into(),
858            },
859            attributes: sattr3 {
860                mode: Nfs3Option::Some(mode),
861                uid: Nfs3Option::None,
862                gid: Nfs3Option::None,
863                size: Nfs3Option::None,
864                atime: set_atime::DONT_CHANGE,
865                mtime: set_mtime::DONT_CHANGE,
866            },
867        };
868
869        let mkdir_res = connection.lock().await.mkdir(&mkdir_args).await?;
870
871        match mkdir_res {
872            MKDIR3res::Ok(ok_res) => {
873                if let Nfs3Option::Some(obj) = ok_res.obj {
874                    fh_cache.lock().await.insert(path.to_string(), obj);
875                }
876                Ok(json!({
877                    "path": path,
878                    "created": true,
879                    "success": true
880                }))
881            }
882            MKDIR3res::Err((stat, _)) => Err(anyhow::anyhow!("Mkdir failed: {:?}", stat)),
883        }
884    }
885
886    /// NFS remove file operation
887    #[cfg(unix)]
888    async fn op_remove(
889        connection: &Arc<Mutex<NfsConn>>,
890        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
891        data: serde_json::Value,
892    ) -> Result<serde_json::Value> {
893        let path = data["path"].as_str().context("Missing 'path' parameter")?;
894
895        let (dir_path, filename) = path.rsplit_once('/').unwrap_or(("/", path));
896        let dir_fh = Self::resolve_path(connection, fh_cache, dir_path).await?;
897
898        let remove_args = REMOVE3args {
899            object: diropargs3 {
900                dir: dir_fh,
901                name: filename.as_bytes().into(),
902            },
903        };
904
905        let remove_res = connection.lock().await.remove(&remove_args).await?;
906
907        match remove_res {
908            REMOVE3res::Ok(_) => {
909                fh_cache.lock().await.remove(path);
910                Ok(json!({
911                    "path": path,
912                    "removed": true,
913                    "success": true
914                }))
915            }
916            REMOVE3res::Err((stat, _)) => Err(anyhow::anyhow!("Remove failed: {:?}", stat)),
917        }
918    }
919
920    /// NFS rmdir operation
921    #[cfg(unix)]
922    async fn op_rmdir(
923        connection: &Arc<Mutex<NfsConn>>,
924        fh_cache: &Arc<Mutex<HashMap<String, nfs_fh3>>>,
925        data: serde_json::Value,
926    ) -> Result<serde_json::Value> {
927        let path = data["path"].as_str().context("Missing 'path' parameter")?;
928
929        let (dir_path, dirname) = path.rsplit_once('/').unwrap_or(("/", path));
930        let dir_fh = Self::resolve_path(connection, fh_cache, dir_path).await?;
931
932        let rmdir_args = RMDIR3args {
933            object: diropargs3 {
934                dir: dir_fh,
935                name: dirname.as_bytes().into(),
936            },
937        };
938
939        let rmdir_res = connection.lock().await.rmdir(&rmdir_args).await?;
940
941        match rmdir_res {
942            RMDIR3res::Ok(_) => {
943                fh_cache.lock().await.remove(path);
944                Ok(json!({
945                    "path": path,
946                    "removed": true,
947                    "success": true
948                }))
949            }
950            RMDIR3res::Err((stat, _)) => Err(anyhow::anyhow!("Rmdir failed: {:?}", stat)),
951        }
952    }
953
954    /// Parse NFS address into server address and export path
955    /// Format: server:port:/export/path or server:/export/path (default port 2049)
956    fn parse_nfs_address(addr: &str) -> Result<(String, String)> {
957        // Split by last ':' to separate path from server:port
958        if let Some(pos) = addr.rfind(':') {
959            let (server_part, path_part) = addr.split_at(pos);
960            let path = path_part.trim_start_matches(':');
961
962            // Check if path starts with '/' - if so, it's the export path
963            if path.starts_with('/') {
964                let server = if !server_part.contains(':') {
965                    format!("{}:2049", server_part)
966                } else {
967                    server_part.to_string()
968                };
969                return Ok((server, path.to_string()));
970            }
971        }
972
973        // Default format: assume everything before last ':' is server, rest is path
974        Err(anyhow::anyhow!(
975            "Invalid NFS address format. Expected: server:port:/export/path or server:/export/path"
976        ))
977    }
978
979    /// The NFS client needs Unix; elsewhere connecting fails with this error
980    #[cfg(not(unix))]
981    pub async fn connect_with_llm_actions(
982        _remote_addr: String,
983        _llm_client: OllamaClient,
984        _app_state: Arc<AppState>,
985        status_tx: mpsc::UnboundedSender<String>,
986        _client_id: ClientId,
987        _startup_params: Option<crate::protocol::StartupParams>,
988    ) -> Result<SocketAddr> {
989        Log::new(Some(&status_tx)).error("NFS feature not enabled at compile time".to_string());
990        Err(anyhow::anyhow!("NFS feature not enabled"))
991    }
992}