Skip to main content

fxcp_core/nfs/
client.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/nfs/client.rs  --  NFSv4.2 compound RPC TCP client
5
6//! Persistent TCP client for sending NFSv4.2 compound RPCs directly to the
7//! NFS server, bypassing the Linux VFS for small-file writes.
8//!
9//! Manages session establishment (EXCHANGE_ID + CREATE_SESSION), sequence IDs,
10//! and directory handle caching.
11
12#![allow(clippy::unwrap_used)]
13use std::io::{Read, Write};
14use std::net::TcpStream;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
17use dashmap::DashMap;
18use tracing::{debug, info, warn};
19
20use crate::constants;
21
22use super::mount::NfsBypassInfo;
23use super::rpc::{self, Nfs4Op, StateId, WriteStable};
24use super::NfsError;
25
26/// Transport abstraction for NFS compound RPCs.
27///
28/// Without the `tls` or `rdma` features: zero-cost newtype wrapping `TcpStream`.
29/// With the `tls` feature: enum supporting plaintext TCP or TLS 1.3.
30/// With the `rdma` feature: enum supporting plaintext TCP or RDMA Send/Recv.
31/// All variants implement `Read + Write` so stream-based I/O sites are unchanged.
32/// The RDMA variant bypasses Read/Write  --  it uses `rpc_call()` directly.
33#[cfg(not(any(feature = "tls", feature = "rdma")))]
34pub(crate) struct NfsTransport(TcpStream);
35
36#[cfg(not(any(feature = "tls", feature = "rdma")))]
37impl NfsTransport {
38    /// Wrap a TcpStream in NfsTransport (zero cost without tls feature).
39    pub fn new(s: TcpStream) -> Self { Self(s) }
40    /// Borrow the underlying TcpStream for socket option calls.
41    pub fn tcp_ref(&self) -> &TcpStream { &self.0 }
42    /// Apply standard TCP options: no-delay, read/write timeouts.
43    pub fn configure_tcp(&self) -> std::io::Result<()> {
44        use std::time::Duration;
45        self.0.set_nodelay(true)?;
46        self.0.set_read_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
47        self.0.set_write_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
48        Ok(())
49    }
50}
51
52#[cfg(not(any(feature = "tls", feature = "rdma")))]
53impl std::io::Read for NfsTransport {
54    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { self.0.read(buf) }
55}
56
57#[cfg(not(any(feature = "tls", feature = "rdma")))]
58impl std::io::Write for NfsTransport {
59    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { self.0.write(buf) }
60    fn flush(&mut self) -> std::io::Result<()> { self.0.flush() }
61}
62
63/// RDMA transport (without TLS  --  mutually exclusive per RFC 9289 S5.1.3).
64///
65/// Enum with TCP fallback for session establishment (EXCHANGE_ID, CREATE_SESSION)
66/// and RDMA for data-path RPC compounds. TCP is used initially; the RDMA variant
67/// is selected when `proto=rdma` is detected in mount options (Wave 5).
68#[cfg(all(feature = "rdma", not(feature = "tls")))]
69pub(crate) enum NfsTransport {
70    /// Plaintext TCP connection (default, used for session establishment).
71    Tcp(TcpStream),
72    /// RDMA Send/Recv connection (RFC 8166). Does NOT implement Read/Write  -- 
73    /// all I/O goes through `NfsCompoundClient::rpc_call()`.
74    Rdma(super::rdma_transport::RdmaConnection),
75}
76
77#[cfg(all(feature = "rdma", not(feature = "tls")))]
78impl NfsTransport {
79    /// Wrap a TcpStream as a plaintext transport.
80    pub fn new(s: TcpStream) -> Self { NfsTransport::Tcp(s) }
81    /// Borrow the underlying TcpStream for socket option calls.
82    ///
83    /// # Panics
84    ///
85    /// Panics if called on an `NfsTransport::Rdma`  --  RDMA has no TCP socket.
86    pub fn tcp_ref(&self) -> &TcpStream {
87        match self {
88            NfsTransport::Tcp(s) => s,
89            NfsTransport::Rdma(_) => unreachable!("RDMA transport has no TCP socket"),
90        }
91    }
92    /// Apply standard TCP options to the inner TcpStream.
93    pub fn configure_tcp(&self) -> std::io::Result<()> {
94        use std::time::Duration;
95        let tcp = self.tcp_ref();
96        tcp.set_nodelay(true)?;
97        tcp.set_read_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
98        tcp.set_write_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
99        Ok(())
100    }
101}
102
103#[cfg(all(feature = "rdma", not(feature = "tls")))]
104impl std::io::Read for NfsTransport {
105    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
106        match self {
107            NfsTransport::Tcp(s) => s.read(buf),
108            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly, not Read"),
109        }
110    }
111}
112
113#[cfg(all(feature = "rdma", not(feature = "tls")))]
114impl std::io::Write for NfsTransport {
115    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
116        match self {
117            NfsTransport::Tcp(s) => s.write(buf),
118            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly, not Write"),
119        }
120    }
121    fn flush(&mut self) -> std::io::Result<()> {
122        match self {
123            NfsTransport::Tcp(s) => s.flush(),
124            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly, not Write"),
125        }
126    }
127}
128
129#[cfg(feature = "tls")]
130pub(crate) enum NfsTransport {
131    /// Plaintext TCP connection (default / pre-TLS-upgrade).
132    Tcp(TcpStream),
133    /// TLS 1.3 connection established via RFC 9289 STARTTLS upgrade.
134    Tls(Box<rustls::StreamOwned<rustls::ClientConnection, TcpStream>>),
135    /// kTLS-promoted socket  --  kernel handles AES-GCM transparently.
136    /// After promotion the rustls `ClientConnection` is consumed (secrets
137    /// extracted via `dangerous_extract_secrets`), so I/O goes directly
138    /// through the `TcpStream` fd where the kernel TLS ULP encrypts/decrypts.
139    #[cfg(feature = "ktls")]
140    Ktls(TcpStream),
141    /// RDMA Send/Recv connection (RFC 8166). Mutually exclusive with TLS
142    /// per RFC 9289 S5.1.3  --  use krb5p for encryption over RDMA.
143    #[cfg(feature = "rdma")]
144    Rdma(super::rdma_transport::RdmaConnection),
145}
146
147#[cfg(feature = "tls")]
148impl NfsTransport {
149    /// Wrap a TcpStream as a plaintext transport.
150    pub fn new(s: TcpStream) -> Self { NfsTransport::Tcp(s) }
151    /// Borrow the underlying TcpStream for socket option calls.
152    pub fn tcp_ref(&self) -> &TcpStream {
153        match self {
154            NfsTransport::Tcp(s) => s,
155            NfsTransport::Tls(s) => s.get_ref(),
156            #[cfg(feature = "ktls")]
157            NfsTransport::Ktls(s) => s,
158            #[cfg(feature = "rdma")]
159            NfsTransport::Rdma(_) => unreachable!("RDMA transport has no TCP socket"),
160        }
161    }
162    /// Apply standard TCP options to the inner TcpStream.
163    pub fn configure_tcp(&self) -> std::io::Result<()> {
164        use std::time::Duration;
165        let tcp = self.tcp_ref();
166        tcp.set_nodelay(true)?;
167        tcp.set_read_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
168        tcp.set_write_timeout(Some(Duration::from_secs(constants::NFS_TCP_RW_TIMEOUT_SECS)))?;
169        Ok(())
170    }
171}
172
173#[cfg(feature = "tls")]
174impl std::io::Read for NfsTransport {
175    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
176        match self {
177            NfsTransport::Tcp(s) => s.read(buf),
178            NfsTransport::Tls(s) => s.read(buf),
179            #[cfg(feature = "ktls")]
180            NfsTransport::Ktls(s) => s.read(buf),
181            #[cfg(feature = "rdma")]
182            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly"),
183        }
184    }
185}
186
187#[cfg(feature = "tls")]
188impl std::io::Write for NfsTransport {
189    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
190        match self {
191            NfsTransport::Tcp(s) => s.write(buf),
192            NfsTransport::Tls(s) => s.write(buf),
193            #[cfg(feature = "ktls")]
194            NfsTransport::Ktls(s) => s.write(buf),
195            #[cfg(feature = "rdma")]
196            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly"),
197        }
198    }
199    fn flush(&mut self) -> std::io::Result<()> {
200        match self {
201            NfsTransport::Tcp(s) => s.flush(),
202            NfsTransport::Tls(s) => s.flush(),
203            #[cfg(feature = "ktls")]
204            NfsTransport::Ktls(s) => s.flush(),
205            #[cfg(feature = "rdma")]
206            NfsTransport::Rdma(_) => unreachable!("RDMA uses rpc_call() directly"),
207        }
208    }
209}
210
211#[cfg(target_os = "linux")]
212impl NfsTransport {
213    /// Raw file descriptor of the underlying TCP socket (for setsockopt).
214    #[allow(dead_code)]
215    pub fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
216        use std::os::unix::io::AsRawFd;
217        self.tcp_ref().as_raw_fd()
218    }
219}
220
221#[cfg(feature = "tls")]
222impl NfsTransport {
223    /// Export TLS keying material for RFC 9266 tls-exporter channel binding.
224    ///
225    /// Returns 32-byte EKM when the transport is TLS-protected, `None` for plaintext.
226    /// The EKM label is `EXPORTER-Channel-Binding` per RFC 9266 S3.
227    pub fn export_channel_binding(&self) -> Option<Vec<u8>> {
228        match self {
229            NfsTransport::Tls(tls) => {
230                let mut ekm = [0u8; 32];
231                match tls.conn.export_keying_material(&mut ekm, b"EXPORTER-Channel-Binding", None) {
232                    Ok(_) => Some(ekm.to_vec()),
233                    Err(e) => {
234                        tracing::warn!("TLS EKM export failed (channel binding unavailable): {e}");
235                        None
236                    }
237                }
238            }
239            NfsTransport::Tcp(_) => None,
240            #[cfg(feature = "ktls")]
241            NfsTransport::Ktls(_) => None,
242            #[cfg(feature = "rdma")]
243            NfsTransport::Rdma(_) => None,
244        }
245    }
246}
247
248/// AIMD self-tuner for NFSv4 compound RPC operation count.
249///
250/// Increases files-per-compound after sustained success (additive increase)
251/// and halves on server rejection (multiplicative decrease).
252/// Derived from server's negotiated ca_maxoperations in CREATE_SESSION.
253struct CompoundSizeTuner {
254    /// Current target: files per compound [1..=negotiated_ceiling]
255    current_max: AtomicUsize,
256    /// Server's negotiated limit: (ca_maxoperations - 2) / 4
257    negotiated_ceiling: usize,
258    /// Consecutive successes at current_max (reset on increase or error)
259    consecutive_success: AtomicU32,
260}
261
262impl CompoundSizeTuner {
263    fn new(negotiated_max_ops: u32) -> Self {
264        // (ca_maxops - 2 header ops) / 3 ops per file (OPEN+WRITE+CLOSE)
265        let ceiling = ((negotiated_max_ops.saturating_sub(2)) / 3).max(1) as usize;
266        // Start conservative at 3 (current baseline), grow to ceiling
267        Self {
268            current_max: AtomicUsize::new(ceiling.min(3)),
269            negotiated_ceiling: ceiling,
270            consecutive_success: AtomicU32::new(0),
271        }
272    }
273
274    /// Current files-per-compound target.
275    fn current(&self) -> usize {
276        self.current_max.load(Ordering::Relaxed)
277    }
278
279    /// Called after a successful compound. Adds 1 after 10 consecutive successes.
280    fn on_success(&self) {
281        let succ = self.consecutive_success.fetch_add(1, Ordering::Relaxed) + 1;
282        if succ >= 10 {
283            let cur = self.current_max.load(Ordering::Relaxed);
284            if cur < self.negotiated_ceiling {
285                self.current_max.store(cur + 1, Ordering::Relaxed);
286                self.consecutive_success.store(0, Ordering::Relaxed);
287                debug!("CompoundSizeTuner: increased to {} files/compound", cur + 1);
288            }
289        }
290    }
291
292    /// Called on NFS4ERR_REQ_TOO_BIG (10019) or NFS4ERR_REP_TOO_BIG (10028).
293    fn on_size_error(&self) {
294        let cur = self.current_max.load(Ordering::Relaxed);
295        self.current_max.store((cur / 2).max(1), Ordering::Relaxed);
296        self.consecutive_success.store(0, Ordering::Relaxed);
297        warn!("CompoundSizeTuner: halved to {} files/compound", (cur / 2).max(1));
298    }
299}
300
301/// NFSv4.2 compound RPC client over TCP.
302///
303/// Sends file creation compounds directly to the NFS server, bypassing VFS.
304/// One TCP connection, one session, sequential slot usage.
305pub struct NfsCompoundClient {
306    stream: NfsTransport,
307    session_id: [u8; 16],
308    sequence_id: AtomicU32,
309    client_id: u64,
310    uid: u32,
311    gid: u32,
312    machine: String,
313    xid_counter: AtomicU32,
314    server_info: NfsBypassInfo,
315    /// Cache of directory path -> NFS file handle.
316    dir_handle_cache: DashMap<PathBuf, Vec<u8>>,
317    /// AIMD tuner for files-per-compound sizing.
318    compound_tuner: CompoundSizeTuner,
319    /// GSS security context for RPCSEC_GSS (krb5/krb5i/krb5p mounts).
320    #[cfg(feature = "krb5")]
321    gss_ctx: Option<super::gss::GssContext>,
322}
323
324/// Set TCP_QUICKACK to disable delayed ACKs for this send/recv cycle.
325/// Must be called before each read_reply()  --  TCP_QUICKACK resets after each ACK.
326#[cfg(target_os = "linux")]
327fn set_quickack(stream: &NfsTransport) {
328    use std::os::unix::io::AsRawFd;
329    let val: libc::c_int = 1;
330    // SAFETY: tcp_ref() returns a valid TCP socket fd. TCP_QUICKACK is a standard Linux socket option.
331    let ret = unsafe {
332        libc::setsockopt(
333            stream.tcp_ref().as_raw_fd(),
334            libc::IPPROTO_TCP,
335            libc::TCP_QUICKACK,
336            &val as *const _ as *const libc::c_void,
337            std::mem::size_of_val(&val) as libc::socklen_t,
338        )
339    };
340    if ret != 0 {
341        tracing::debug!("TCP_QUICKACK setsockopt failed: {}", std::io::Error::last_os_error());
342    }
343}
344
345#[cfg(not(target_os = "linux"))]
346fn set_quickack(_stream: &NfsTransport) {}
347
348impl NfsCompoundClient {
349    /// Connect to the NFS server and establish a session.
350    ///
351    /// Performs TCP connect -> EXCHANGE_ID -> CREATE_SESSION.
352    pub fn connect(info: &NfsBypassInfo) -> Result<Self, NfsError> {
353        // NFS servers default to `secure`  --  require source port <1024.
354        // Bind to a privileged port before connecting (requires root/CAP_NET_BIND_SERVICE).
355        let tcp_stream = Self::connect_privileged(&info.server_addr)?;
356        let stream = NfsTransport::new(tcp_stream);
357        stream.configure_tcp()?;
358        // Disable delayed ACKs for lower RPC latency
359        set_quickack(&stream);
360
361        // Use effective UID/GID directly. The NFS server handles root_squash
362        // mapping  --  we send our real credentials and let the server decide.
363        // SAFETY: getuid() is always safe and cannot fail.
364        let uid = unsafe { libc::getuid() };
365        // SAFETY: getgid() is always safe and cannot fail.
366        let gid = unsafe { libc::getgid() };
367        let machine = {
368            let mut buf = [0u8; constants::NFS_HOSTNAME_BUFFER_SIZE];
369            // SAFETY: buf is a valid stack buffer. gethostname writes
370            // at most buf.len() bytes including the null terminator.
371            if unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) } == 0 {
372                let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
373                String::from_utf8_lossy(&buf[..len]).to_string()
374            } else {
375                "foxing".to_string()
376            }
377        };
378
379        let mut client = Self {
380            stream,
381            session_id: [0u8; 16],
382            sequence_id: AtomicU32::new(1),
383            client_id: 0,
384            uid,
385            gid,
386            machine,
387            xid_counter: AtomicU32::new(1),
388            server_info: info.clone(),
389            dir_handle_cache: DashMap::new(),
390            compound_tuner: CompoundSizeTuner::new(crate::constants::NFS_COMPOUND_TUNER_INITIAL_MAX_OPS),
391            #[cfg(feature = "krb5")]
392            gss_ctx: None,
393        };
394
395        // TLS transport upgrade (RFC 9289 RPC-over-TLS) if xprtsec= mount option detected
396        #[cfg(feature = "tls")]
397        {
398            use super::NfsTransportSecurity;
399            match info.transport_security {
400                NfsTransportSecurity::Tls => {
401                    client.try_tls_upgrade()?;
402                    info!("NFS bypass: TLS transport active (xprtsec=tls)");
403                }
404                NfsTransportSecurity::Opportunistic => {
405                    match client.try_tls_upgrade() {
406                        Ok(()) => info!("NFS bypass: TLS transport active (opportunistic)"),
407                        Err(NfsError::TlsNotSupported) => {
408                            warn!(
409                                "NFS bypass: server {} does not support xprtsec=tls, \
410                                 continuing with cleartext transport",
411                                info.server_addr
412                            );
413                            crate::metrics::NFS_BYPASS_TLS_FALLBACK.inc();
414                        }
415                        Err(e) => {
416                            warn!("NFS bypass: TLS opportunistic upgrade failed ({}), \
417                                   continuing cleartext", e);
418                            crate::metrics::NFS_BYPASS_TLS_FALLBACK.inc();
419                        }
420                    }
421                }
422                NfsTransportSecurity::None => {}
423            }
424        }
425
426        // Double-encryption advisory: sec=krb5p + TLS = redundant AES layers
427        #[cfg(all(feature = "tls", feature = "krb5"))]
428        if info.security == super::NfsSecurity::Krb5p
429            && info.transport_security != super::NfsTransportSecurity::None
430        {
431            warn!(
432                "NFS bypass: double encryption detected  --  sec=krb5p (per-RPC AES-256) AND \
433                 xprtsec=tls (TLS 1.3 AES-GCM) are both active. \
434                 Consider sec=krb5,xprtsec=tls for ~5% overhead instead of ~30%. \
435                 Both layers provide full confidentiality; combining adds no security gain."
436            );
437            crate::metrics::NFS_BYPASS_DOUBLE_ENCRYPTION.inc();
438        }
439
440        // Session bootstrap: EXCHANGE_ID -> CREATE_SESSION
441        client.establish_session()?;
442
443        // If mount requires Kerberos, attempt GSS context establishment.
444        // On failure, log warning and continue with AUTH_SYS (server may reject).
445        #[cfg(feature = "krb5")]
446        if info.security.requires_kerberos() {
447            // RFC 9266 tls-exporter channel binding: bind GSS context to TLS session
448            #[cfg(feature = "tls")]
449            let channel_binding = client.stream.export_channel_binding();
450            #[cfg(not(feature = "tls"))]
451            let channel_binding: Option<Vec<u8>> = None;
452
453            let hostname = info.server_hostname.clone();
454            match super::gss::GssContext::establish(
455                &hostname,
456                info.security,
457                channel_binding.as_deref(),
458            ) {
459                Ok(mut gss) => {
460                    match client.do_rpcsec_gss_init(&mut gss) {
461                        Ok(()) => {
462                            info!(
463                                "NFS bypass: RPCSEC_GSS context ready (sec={}, server_handle={} bytes)",
464                                info.security, gss.server_handle.len()
465                            );
466                            client.gss_ctx = Some(gss);
467                        }
468                        Err(e) => {
469                            warn!(
470                                "NFS bypass: RPCSEC_GSS INIT failed for {} (sec={}): {}  --  \
471                                 falling back to AUTH_SYS",
472                                info.server_addr, info.security, e
473                            );
474                        }
475                    }
476                }
477                Err(e) => {
478                    warn!(
479                        "NFS bypass: GSS context failed for {} (sec={}): {}  --  \
480                         falling back to AUTH_SYS (may be rejected by server)",
481                        info.server_addr, info.security, e
482                    );
483                }
484            }
485        }
486
487        info!("NFS bypass: session established with {} (client_id={:#x})",
488              info.server_addr, client.client_id);
489
490        Ok(client)
491    }
492
493    /// Establish NFSv4.1+ session via EXCHANGE_ID + CREATE_SESSION.
494    fn establish_session(&mut self) -> Result<(), NfsError> {
495        // Step 1: EXCHANGE_ID  --  register client identity with server
496        let client_id = self.do_exchange_id()?;
497        self.client_id = client_id;
498        debug!("NFS bypass: EXCHANGE_ID ok, client_id={:#x}", client_id);
499
500        // Step 2: CREATE_SESSION  --  get session_id and slot table
501        let session_id = self.do_create_session(client_id)?;
502        self.session_id = session_id;
503        self.sequence_id.store(1, Ordering::Relaxed);
504        debug!("NFS bypass: CREATE_SESSION ok, session={:02x?}", &session_id[..4]);
505
506        // Step 3: RECLAIM_COMPLETE  --  tell server we have no state to reclaim.
507        // This ends the grace period for our session so OPEN works immediately.
508        self.do_reclaim_complete()?;
509        debug!("NFS bypass: RECLAIM_COMPLETE ok");
510
511        Ok(())
512    }
513
514    /// Send EXCHANGE_ID compound to get a client_id from the server.
515    fn do_exchange_id(&mut self) -> Result<u64, NfsError> {
516        let xid = self.next_xid();
517
518        // Build EXCHANGE_ID compound (no SEQUENCE  --  it's a session-creating op)
519        let mut body = super::xdr::XdrEncoder::new(constants::NFS_XDR_ENCODER_SMALL_CAPACITY);
520
521        // RPC header
522        body.encode_u32(xid);
523        body.encode_u32(0); // CALL
524        body.encode_u32(rpc::RPC_VERSION);
525        body.encode_u32(rpc::NFS_PROGRAM);
526        body.encode_u32(rpc::NFS_V4);
527        body.encode_u32(rpc::NFSPROC4_COMPOUND);
528
529        // AUTH_SYS
530        encode_auth_sys(&mut body, self.uid, self.gid, &self.machine);
531        // Verifier AUTH_NONE
532        body.encode_u32(rpc::AUTH_NONE);
533        body.encode_u32(0);
534
535        // COMPOUND args: tag, minorversion=2, ops=[EXCHANGE_ID]
536        body.encode_string("exid");
537        body.encode_u32(2); // minorversion
538        body.encode_u32(1); // 1 operation
539
540        // EXCHANGE_ID operation
541        body.encode_u32(rpc::OP_EXCHANGE_ID);
542        // eia_clientowner: co_verifier(8) + co_ownerid(opaque)
543        let verifier = std::time::SystemTime::now()
544            .duration_since(std::time::UNIX_EPOCH)
545            .unwrap_or_default()
546            .as_nanos() as u64;
547        body.encode_u64(verifier); // co_verifier
548        let owner_id = format!("foxing.{}.{}", self.machine, std::process::id());
549        body.encode_opaque(owner_id.as_bytes()); // co_ownerid
550        // eia_flags
551        body.encode_u32(0x00000001); // EXCHGID4_FLAG_SUPP_MOVED_REFER
552        // eia_state_protect: SP4_NONE
553        body.encode_u32(0); // SP4_NONE
554        // eia_client_impl_id: empty array
555        body.encode_u32(0); // 0 elements
556
557        let body_bytes = body.into_bytes();
558        let reply_data = self.rpc_call(xid, &body_bytes)?;
559        let reply = rpc::parse_compound_reply(&reply_data)?;
560
561        if reply.status != rpc::NFS4_OK {
562            return Err(NfsError::SessionFailed(format!(
563                "EXCHANGE_ID failed: {} ({})", rpc::nfs4_error_name(reply.status), reply.status
564            )));
565        }
566
567        let client_id = self.extract_exchange_id_client_id(&reply_data)?;
568        Ok(client_id)
569    }
570
571    /// Extract client_id from EXCHANGE_ID reply by scanning for it in the raw data.
572    fn extract_exchange_id_client_id(&self, data: &[u8]) -> Result<u64, NfsError> {
573        // After the RPC reply header and compound header, find EXCHANGE_ID result.
574        // The EXCHANGE_ID result starts after: op(4) + status(4), then:
575        //   eir_clientid(8), eir_sequenceid(4), eir_flags(4), ...
576        // We scan backwards from known structure. Simplified: look for the op code
577        // OP_EXCHANGE_ID (42) followed by NFS4_OK (0), then read the next 8 bytes.
578        let needle = [
579            0, 0, 0, rpc::OP_EXCHANGE_ID as u8,  // op = 42
580            0, 0, 0, 0,                            // status = 0 (NFS4_OK)
581        ];
582        if let Some(pos) = data.windows(8).position(|w| w == needle) {
583            let cid_start = pos + 8;
584            if cid_start + 8 <= data.len() {
585                let client_id = u64::from_be_bytes([
586                    data[cid_start], data[cid_start+1], data[cid_start+2], data[cid_start+3],
587                    data[cid_start+4], data[cid_start+5], data[cid_start+6], data[cid_start+7],
588                ]);
589                return Ok(client_id);
590            }
591        }
592        Err(NfsError::SessionFailed("cannot parse client_id from EXCHANGE_ID reply".into()))
593    }
594
595    /// Send CREATE_SESSION compound to get a session_id.
596    fn do_create_session(&mut self, client_id: u64) -> Result<[u8; 16], NfsError> {
597        let xid = self.next_xid();
598
599        let mut body = super::xdr::XdrEncoder::new(constants::NFS_XDR_ENCODER_SMALL_CAPACITY);
600
601        // RPC header
602        body.encode_u32(xid);
603        body.encode_u32(0); // CALL
604        body.encode_u32(rpc::RPC_VERSION);
605        body.encode_u32(rpc::NFS_PROGRAM);
606        body.encode_u32(rpc::NFS_V4);
607        body.encode_u32(rpc::NFSPROC4_COMPOUND);
608
609        // AUTH_SYS
610        encode_auth_sys(&mut body, self.uid, self.gid, &self.machine);
611        body.encode_u32(rpc::AUTH_NONE);
612        body.encode_u32(0);
613
614        // COMPOUND args
615        body.encode_string("csess");
616        body.encode_u32(2); // minorversion
617        body.encode_u32(1); // 1 operation
618
619        // CREATE_SESSION operation (RFC 8881 S18.36)
620        body.encode_u32(rpc::OP_CREATE_SESSION);
621        body.encode_u64(client_id);          // csa_clientid
622        body.encode_u32(1);                   // csa_sequence (from EXCHANGE_ID eir_sequenceid)
623        body.encode_u32(0);                   // csa_flags: 0 (no persist, no back channel)
624        // csa_fore_chan_attrs (channel_attrs4):
625        //   headerpadsize, maxrequestsize, maxresponsesize,
626        //   maxresponsesize_cached, maxoperations, maxrequests, rdma_ird[]
627        body.encode_u32(0);                   // ca_headerpadsize
628        body.encode_u32(crate::constants::NFS_MAX_REQUEST_SIZE as u32); // ca_maxrequestsize
629        body.encode_u32(crate::constants::NFS_MAX_RESPONSE_SIZE as u32); // ca_maxresponsesize
630        body.encode_u32(4096);                // ca_maxresponsesize_cached
631        body.encode_u32(crate::constants::NFS_CA_MAX_OPERATIONS);  // ca_maxoperations  --  request higher limit; server negotiates down
632        body.encode_u32(1);                   // ca_maxrequests (1 slot)
633        body.encode_u32(0);                   // ca_rdma_ird count (empty array)
634        // csa_back_chan_attrs (minimal  --  no back channel)
635        body.encode_u32(0);                   // ca_headerpadsize
636        body.encode_u32(4096);                // ca_maxrequestsize
637        body.encode_u32(4096);                // ca_maxresponsesize
638        body.encode_u32(0);                   // ca_maxresponsesize_cached
639        body.encode_u32(2);                   // ca_maxoperations
640        body.encode_u32(0);                   // ca_maxrequests (0 = no back channel)
641        body.encode_u32(0);                   // ca_rdma_ird count
642        // csa_cb_program
643        body.encode_u32(0x40000000);          // callback program number (unused)
644        // csa_sec_parms: callback_sec_parms4[]
645        // For AUTH_SYS: secflavor(4) + authsys_parms
646        body.encode_u32(1);                   // array count: 1 element
647        body.encode_u32(rpc::AUTH_SYS);       // cb_secflavor
648        // authsys_parms (cbsp_sys_cred):
649        body.encode_u32(0);                   // stamp
650        body.encode_string(&self.machine);    // machinename
651        body.encode_u32(self.uid);            // uid
652        body.encode_u32(self.gid);            // gid
653        body.encode_u32(1);                   // gids count
654        body.encode_u32(self.gid);            // gids[0]
655
656        let body_bytes = body.into_bytes();
657        let reply_data = self.rpc_call(xid, &body_bytes)?;
658        let reply = rpc::parse_compound_reply(&reply_data)?;
659
660        if reply.status != rpc::NFS4_OK {
661            return Err(NfsError::SessionFailed(format!(
662                "CREATE_SESSION failed: {} ({})", rpc::nfs4_error_name(reply.status), reply.status
663            )));
664        }
665
666        // Extract session_id and negotiated ca_maxrequestsize from CREATE_SESSION reply
667        let session_id = self.extract_session_id(&reply_data)?;
668
669        // Log server's negotiated channel attributes for debugging size limits
670        let needle = [
671            0, 0, 0, rpc::OP_CREATE_SESSION as u8,
672            0, 0, 0, 0,
673        ];
674        if let Some(pos) = reply_data.windows(8).position(|w| w == needle) {
675            // CREATE_SESSION4resok: session_id(16) + sequence_id(4) + flags(4) + fore_chan_attrs
676            // fore_chan_attrs: headerpadsize(4) + maxrequestsize(4) + maxresponsesize(4) + ...
677            let attrs_start = pos + 8 + 16 + 4 + 4; // skip session_id + seq + flags
678            if attrs_start + 12 <= reply_data.len() {
679                let max_req = u32::from_be_bytes([
680                    reply_data[attrs_start + 4], reply_data[attrs_start + 5],
681                    reply_data[attrs_start + 6], reply_data[attrs_start + 7],
682                ]);
683                let max_ops = if attrs_start + 20 <= reply_data.len() {
684                    u32::from_be_bytes([
685                        reply_data[attrs_start + 16], reply_data[attrs_start + 17],
686                        reply_data[attrs_start + 18], reply_data[attrs_start + 19],
687                    ])
688                } else { 0 };
689                debug!(
690                    "NFS bypass: server negotiated ca_maxrequestsize={} ca_maxoperations={}",
691                    max_req, max_ops
692                );
693            }
694        }
695
696        Ok(session_id)
697    }
698
699    /// Extract session_id (16 bytes) from CREATE_SESSION reply.
700    fn extract_session_id(&self, data: &[u8]) -> Result<[u8; 16], NfsError> {
701        // CREATE_SESSION result starts after op(4) + status(4), then session_id(16)
702        let needle = [
703            0, 0, 0, rpc::OP_CREATE_SESSION as u8,
704            0, 0, 0, 0,
705        ];
706        if let Some(pos) = data.windows(8).position(|w| w == needle) {
707            let sid_start = pos + 8;
708            if sid_start + 16 <= data.len() {
709                let mut session_id = [0u8; 16];
710                session_id.copy_from_slice(&data[sid_start..sid_start + 16]);
711                return Ok(session_id);
712            }
713        }
714        Err(NfsError::SessionFailed("cannot parse session_id from CREATE_SESSION reply".into()))
715    }
716
717    /// Send RECLAIM_COMPLETE to end the grace period for our session.
718    fn do_reclaim_complete(&mut self) -> Result<(), NfsError> {
719        let seq_id = self.next_sequence_id();
720        let ops = vec![
721            Nfs4Op::Sequence {
722                session_id: self.session_id,
723                sequence_id: seq_id,
724                slot_id: 0,
725                highest_slot_id: 0,
726                cache_this: false,
727            },
728            Nfs4Op::ReclaimComplete,
729        ];
730        let xid = self.next_xid();
731        let msg = self.build_compound_msg(xid, "rclm", &ops)?;
732        let reply_data = self.rpc_call(xid, &msg)?;
733        let reply = rpc::parse_compound_reply(&reply_data)?;
734        if reply.status != rpc::NFS4_OK {
735            return Err(NfsError::SessionFailed(format!(
736                "RECLAIM_COMPLETE failed: {} ({})", rpc::nfs4_error_name(reply.status), reply.status
737            )));
738        }
739        Ok(())
740    }
741
742    /// Re-establish session after NFS4ERR_BADSESSION or connection loss.
743    pub fn recover_session(&mut self) -> Result<(), NfsError> {
744        warn!("NFS bypass: recovering session...");
745        // Reconnect TCP
746        let new_tcp = TcpStream::connect_timeout(
747            &self.server_info.server_addr,
748            std::time::Duration::from_secs(constants::NFS_TCP_CONNECT_TIMEOUT_SECS),
749        )?;
750        self.stream = NfsTransport::new(new_tcp);
751        self.stream.configure_tcp()?;
752        set_quickack(&self.stream);
753
754        // Re-establish TLS if the original connection used it
755        #[cfg(feature = "tls")]
756        {
757            use super::NfsTransportSecurity;
758            match self.server_info.transport_security {
759                NfsTransportSecurity::Tls => {
760                    self.try_tls_upgrade()?;
761                    debug!("NFS recovery: TLS re-established");
762                }
763                NfsTransportSecurity::Opportunistic => {
764                    match self.try_tls_upgrade() {
765                        Ok(()) => debug!("NFS recovery: TLS re-established (opportunistic)"),
766                        Err(e) => {
767                            warn!("NFS recovery: TLS re-upgrade failed ({}), continuing cleartext", e);
768                            crate::metrics::NFS_BYPASS_TLS_FALLBACK.inc();
769                        }
770                    }
771                }
772                NfsTransportSecurity::None => {}
773            }
774        }
775
776        // Re-establish session
777        self.establish_session()?;
778
779        // Clear handle cache (handles may be stale after session loss)
780        self.dir_handle_cache.clear();
781
782        // Re-establish GSS context if Kerberos mount
783        #[cfg(feature = "krb5")]
784        if self.server_info.security.requires_kerberos() {
785            #[cfg(feature = "tls")]
786            let channel_binding = self.stream.export_channel_binding();
787            #[cfg(not(feature = "tls"))]
788            let channel_binding: Option<Vec<u8>> = None;
789
790            let hostname = self.server_info.server_hostname.clone();
791            match super::gss::GssContext::establish(
792                &hostname,
793                self.server_info.security,
794                channel_binding.as_deref(),
795            ) {
796                Ok(mut gss) => {
797                    match self.do_rpcsec_gss_init(&mut gss) {
798                        Ok(()) => {
799                            self.gss_ctx = Some(gss);
800                            debug!("NFS bypass: GSS context re-established and INIT complete after recovery");
801                        }
802                        Err(e) => {
803                            warn!("NFS bypass: RPCSEC_GSS INIT failed after recovery: {}  --  AUTH_SYS fallback", e);
804                            self.gss_ctx = None;
805                        }
806                    }
807                }
808                Err(e) => {
809                    warn!("NFS bypass: GSS re-establishment failed: {}  --  AUTH_SYS fallback", e);
810                    self.gss_ctx = None;
811                }
812            }
813        }
814
815        info!("NFS bypass: session recovered (client_id={:#x})", self.client_id);
816        Ok(())
817    }
818
819    /// Refresh the GSS context if it is expired or near expiry.
820    /// No-op when the krb5 feature is disabled or no GSS context is active.
821    pub fn ensure_gss_valid(&mut self) {
822        #[cfg(feature = "krb5")]
823        {
824            let needs_refresh = self.gss_ctx.as_mut().map(|g| !g.is_valid()).unwrap_or(false);
825            if needs_refresh {
826                self.refresh_gss_context();
827            }
828        }
829    }
830
831    /// Unwrap GSS-protected reply data before parsing.
832    /// No-op without krb5 feature or when no GSS context is active.
833    fn unwrap_reply(&mut self, data: Vec<u8>) -> Result<Vec<u8>, NfsError> {
834        #[cfg(feature = "krb5")]
835        if let Some(ref mut gss) = self.gss_ctx {
836            return rpc::unwrap_gss_reply(&data, gss);
837        }
838        Ok(data)
839    }
840
841    fn build_compound_msg(&mut self, xid: u32, tag: &str, ops: &[Nfs4Op]) -> Result<Vec<u8>, NfsError> {
842        #[cfg(feature = "krb5")]
843        {
844            let needs_refresh = self.gss_ctx.as_mut().map(|g| !g.is_valid()).unwrap_or(false);
845            if needs_refresh {
846                self.refresh_gss_context();
847            }
848            if let Some(ref mut gss) = self.gss_ctx {
849                return rpc::build_compound_gss(xid, tag, ops, gss);
850            }
851        }
852        Ok(rpc::build_compound(xid, tag, self.uid, self.gid, &self.machine, ops))
853    }
854
855    /// Perform the RPCSEC_GSS INIT handshake to obtain the server-assigned
856    /// context handle (RFC 2203 S5.2.2). Loops for multi-round GSS exchanges.
857    #[cfg(feature = "krb5")]
858    fn do_rpcsec_gss_init(&mut self, gss: &mut super::gss::GssContext) -> Result<(), NfsError> {
859        const GSS_S_COMPLETE: u32 = 0;
860        const GSS_S_CONTINUE_NEEDED: u32 = 1;
861
862        let mut handle = Vec::<u8>::new();
863        let mut token = gss.init_token.clone();
864
865        loop {
866            let xid = self.next_xid();
867            let msg = rpc::build_rpcsec_gss_init(xid, &handle, &token);
868            let reply = self.rpc_call(xid, &msg)?;
869            let (server_handle, gss_major, gss_minor, _seq_window, cont_token) =
870                rpc::parse_rpcsec_gss_init_reply(&reply)?;
871
872            handle = server_handle;
873
874            match gss_major {
875                GSS_S_COMPLETE => {
876                    // Server is done, but client may need one final
877                    // gss_init_sec_context() to process the server's reply token.
878                    if !cont_token.is_empty() {
879                        let _ = gss.step(&cont_token);
880                    }
881                    gss.server_handle = handle;
882                    debug!(
883                        "RPCSEC_GSS INIT complete, handle={} bytes",
884                        gss.server_handle.len()
885                    );
886                    return Ok(());
887                }
888                GSS_S_CONTINUE_NEEDED => {
889                    match gss.step(&cont_token)? {
890                        Some(new_token) => {
891                            token = new_token;
892                        }
893                        None => {
894                            return Err(NfsError::RpcError(
895                                "GSS_S_CONTINUE_NEEDED but no output token from gss.step()"
896                                    .into(),
897                            ));
898                        }
899                    }
900                }
901                _ => {
902                    return Err(NfsError::RpcError(format!(
903                        "RPCSEC_GSS INIT failed: major={} minor={}",
904                        gss_major, gss_minor
905                    )));
906                }
907            }
908        }
909    }
910
911    /// Re-establish an expired GSS context from system keytab/ccache.
912    /// On failure, clears gss_ctx to fall back to AUTH_SYS.
913    #[cfg(feature = "krb5")]
914    fn refresh_gss_context(&mut self) {
915        #[cfg(feature = "tls")]
916        let channel_binding = self.stream.export_channel_binding();
917        #[cfg(not(feature = "tls"))]
918        let channel_binding: Option<Vec<u8>> = None;
919
920        let hostname = self.server_info.server_hostname.clone();
921        match super::gss::GssContext::establish(
922            &hostname,
923            self.server_info.security,
924            channel_binding.as_deref(),
925        ) {
926            Ok(mut new_ctx) => {
927                match self.do_rpcsec_gss_init(&mut new_ctx) {
928                    Ok(()) => {
929                        debug!("NFS bypass: GSS context refreshed (sec={})", self.server_info.security);
930                        self.gss_ctx = Some(new_ctx);
931                    }
932                    Err(e) => {
933                        warn!("NFS bypass: GSS context refresh INIT failed: {e}  --  falling back to AUTH_SYS");
934                        self.gss_ctx = None;
935                    }
936                }
937            }
938            Err(e) => {
939                warn!("NFS bypass: GSS context refresh failed: {e}  --  falling back to AUTH_SYS");
940                self.gss_ctx = None;
941            }
942        }
943    }
944
945    /// Connect to an NFS server from a privileged source port (<1024).
946    /// NFS servers with `secure` (default) reject connections from ports >=1024.
947    fn connect_privileged(server: &std::net::SocketAddr) -> Result<TcpStream, NfsError> {
948        use std::os::unix::io::FromRawFd;
949
950        let domain = if server.is_ipv4() { libc::AF_INET } else { libc::AF_INET6 };
951        // SAFETY: creating a TCP socket. No preconditions beyond valid domain.
952        let fd = unsafe { libc::socket(domain, libc::SOCK_STREAM, 0) };
953        if fd < 0 {
954            return Err(NfsError::ConnectionFailed(std::io::Error::last_os_error()));
955        }
956
957        // Try binding to ports 900-1023 (privileged range, avoids well-known services)
958        let mut bound = false;
959        for port in (constants::NFS_PRIVILEGED_PORT_START..constants::NFS_PRIVILEGED_PORT_END).rev() {
960            let ret = if server.is_ipv4() {
961                let addr = libc::sockaddr_in {
962                    sin_family: libc::AF_INET as u16,
963                    sin_port: port.to_be(),
964                    sin_addr: libc::in_addr { s_addr: 0 },
965                    sin_zero: [0; 8],
966                };
967                // SAFETY: fd is a valid socket, addr is a properly initialized sockaddr_in.
968                unsafe { libc::bind(fd, &addr as *const _ as *const libc::sockaddr, std::mem::size_of::<libc::sockaddr_in>() as u32) }
969            } else {
970                let addr = libc::sockaddr_in6 {
971                    sin6_family: libc::AF_INET6 as u16,
972                    sin6_port: port.to_be(),
973                    sin6_flowinfo: 0,
974                    sin6_addr: libc::in6_addr { s6_addr: [0; 16] },
975                    sin6_scope_id: 0,
976                };
977                // SAFETY: fd is a valid socket, addr is a properly initialized sockaddr_in6.
978                unsafe { libc::bind(fd, &addr as *const _ as *const libc::sockaddr, std::mem::size_of::<libc::sockaddr_in6>() as u32) }
979            };
980            if ret == 0 {
981                bound = true;
982                debug!("NFS bypass: bound to privileged port {}", port);
983                break;
984            }
985        }
986
987        if !bound {
988            // SAFETY: fd is a valid socket descriptor.
989            unsafe { libc::close(fd); }
990            // Fall back to ephemeral port (works if server has `insecure` export option)
991            debug!("NFS bypass: no privileged port available, using ephemeral");
992            return Ok(TcpStream::connect_timeout(server, std::time::Duration::from_secs(constants::NFS_TCP_CONNECT_TIMEOUT_SECS))?);
993        }
994
995        // Connect to server
996        let connect_result = match server {
997            std::net::SocketAddr::V4(v4) => {
998                let addr = libc::sockaddr_in {
999                    sin_family: libc::AF_INET as u16,
1000                    sin_port: v4.port().to_be(),
1001                    sin_addr: libc::in_addr { s_addr: u32::from_ne_bytes(v4.ip().octets()) },
1002                    sin_zero: [0; 8],
1003                };
1004                // SAFETY: fd is a valid bound socket, addr is a properly initialized sockaddr_in.
1005                unsafe { libc::connect(fd, &addr as *const _ as *const libc::sockaddr, std::mem::size_of::<libc::sockaddr_in>() as u32) }
1006            }
1007            std::net::SocketAddr::V6(v6) => {
1008                let addr = libc::sockaddr_in6 {
1009                    sin6_family: libc::AF_INET6 as u16,
1010                    sin6_port: v6.port().to_be(),
1011                    sin6_flowinfo: v6.flowinfo(),
1012                    sin6_addr: libc::in6_addr { s6_addr: v6.ip().octets() },
1013                    sin6_scope_id: v6.scope_id(),
1014                };
1015                // SAFETY: fd is a valid bound socket, addr is a properly initialized sockaddr_in6.
1016                unsafe { libc::connect(fd, &addr as *const _ as *const libc::sockaddr, std::mem::size_of::<libc::sockaddr_in6>() as u32) }
1017            }
1018        };
1019
1020        if connect_result != 0 {
1021            let err = std::io::Error::last_os_error();
1022            // SAFETY: fd is a valid socket descriptor.
1023            unsafe { libc::close(fd); }
1024            return Err(NfsError::ConnectionFailed(err));
1025        }
1026
1027        // SAFETY: fd is a valid connected socket. Ownership transfers to TcpStream.
1028        Ok(unsafe { TcpStream::from_raw_fd(fd) })
1029    }
1030
1031    /// Attempt RFC 9289 STARTTLS upgrade on the current TCP connection.
1032    ///
1033    /// Sends an AUTH_TLS NULL probe. If the server responds with "STARTTLS",
1034    /// performs a TLS 1.3 handshake via rustls with ALPN "sunrpc" and upgrades
1035    /// `self.stream` from `NfsTransport::Tcp` to `NfsTransport::Tls`.
1036    ///
1037    /// Returns `Ok(())` on successful upgrade, `Err(NfsError::TlsNotSupported)`
1038    /// if the server does not support RPC-over-TLS, or `Err(NfsError::TlsHandshakeFailed)`
1039    /// on TLS negotiation errors.
1040    #[cfg(feature = "tls")]
1041    fn try_tls_upgrade(&mut self) -> Result<(), NfsError> {
1042        use rustls::pki_types::ServerName;
1043        use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
1044        use std::sync::Arc;
1045
1046        // Step 1: Send AUTH_TLS probe (RFC 9289 S4.1)
1047        let probe_xid = self.next_xid();
1048        let probe = super::rpc::build_auth_tls_probe(probe_xid);
1049        let reply = self.rpc_call(probe_xid, &probe)
1050            .map_err(|e| NfsError::TlsHandshakeFailed(format!("AUTH_TLS probe: {e}")))?;
1051
1052        // Step 3: Check for STARTTLS in verifier body
1053        if !super::rpc::parse_auth_tls_reply(&reply) {
1054            return Err(NfsError::TlsNotSupported);
1055        }
1056
1057        info!(
1058            "NFS TLS: server {} supports RPC-over-TLS, upgrading connection (RFC 9289)",
1059            self.server_info.server_addr
1060        );
1061
1062        // Step 4: Build rustls ClientConfig  --  system CA store + ALPN "sunrpc"
1063        let roots = {
1064            let mut store = RootCertStore::empty();
1065            for cert in rustls_native_certs::load_native_certs().certs {
1066                let _ = store.add(cert);
1067            }
1068            store
1069        };
1070        let mut config = ClientConfig::builder()
1071            .with_root_certificates(roots)
1072            .with_no_client_auth();
1073        config.alpn_protocols = vec![constants::NFS_TLS_ALPN.to_vec()];
1074        #[cfg(feature = "ktls")]
1075        {
1076            config.enable_secret_extraction = true;
1077        }
1078        let config = Arc::new(config);
1079
1080        // Step 5: Parse server hostname for certificate validation
1081        let hostname = &self.server_info.server_hostname;
1082        let server_name = ServerName::try_from(hostname.as_str())
1083            .map_err(|e| {
1084                NfsError::TlsHandshakeFailed(format!(
1085                    "invalid server hostname '{}' for TLS SNI: {e}",
1086                    hostname
1087                ))
1088            })?
1089            .to_owned();
1090
1091        // Step 6: Clone the TCP socket fd for rustls ownership
1092        // try_clone() creates a dup(2) fd pointing to the same socket.
1093        // The original fd in self.stream is dropped when we reassign below.
1094        let tcp_clone = self
1095            .stream
1096            .tcp_ref()
1097            .try_clone()
1098            .map_err(|e| NfsError::TlsHandshakeFailed(format!("TCP clone: {e}")))?;
1099
1100        // Step 7: Create rustls connection and wrap the TCP stream
1101        // The TLS handshake is driven lazily on the first write (EXCHANGE_ID compound).
1102        let conn = ClientConnection::new(config, server_name)
1103            .map_err(|e| NfsError::TlsHandshakeFailed(format!("rustls init: {e}")))?;
1104        let tls_stream = StreamOwned::new(conn, tcp_clone);
1105
1106        // Step 8: Store the transport  --  with optional kTLS kernel offload.
1107        //
1108        // When the `ktls` feature is enabled we attempt to promote the
1109        // userspace TLS session into a kernel-offloaded (kTLS) socket.
1110        // `try_ktls_or_tls` consumes `tls_stream` on all code paths and
1111        // returns the appropriate `NfsTransport` variant:
1112        //   Ktls  --  promotion succeeded, kernel handles AES-GCM
1113        //   Tls   --  promotion failed pre-destructively, userspace TLS intact
1114        //   Tcp   --  promotion failed post-destructively (secrets consumed),
1115        //          degraded; next RPC error will trigger session recovery
1116        #[cfg(feature = "ktls")]
1117        {
1118            let (transport, promoted) = Self::try_ktls_or_tls(tls_stream);
1119            self.stream = transport;
1120            if promoted {
1121                info!(
1122                    "NFS TLS: kTLS kernel offload active  --  AES-GCM in-kernel, \
1123                     server={}",
1124                    self.server_info.server_addr
1125                );
1126                crate::metrics::NFS_BYPASS_KTLS_PROMOTED.inc();
1127            } else {
1128                info!(
1129                    "NFS TLS: TLS 1.3 handshake ready (will complete on first \
1130                     EXCHANGE_ID write), server={}, alpn=sunrpc",
1131                    self.server_info.server_addr
1132                );
1133            }
1134            return Ok(());
1135        }
1136
1137        // Without ktls feature: always store as userspace TLS.
1138        #[cfg(not(feature = "ktls"))]
1139        {
1140            self.stream = NfsTransport::Tls(Box::new(tls_stream));
1141            info!(
1142                "NFS TLS: TLS 1.3 handshake ready (will complete on first EXCHANGE_ID write), \
1143                 server={}, alpn=sunrpc",
1144                self.server_info.server_addr
1145            );
1146        }
1147        Ok(())
1148    }
1149
1150    /// Attempt kTLS kernel offload, falling back to userspace TLS.
1151    ///
1152    /// Consumes `tls_stream` on every path.  Returns `(transport, promoted)`
1153    /// where `promoted` is true only when kTLS was fully configured.
1154    #[cfg(feature = "ktls")]
1155    fn try_ktls_or_tls(
1156        mut tls_stream: rustls::StreamOwned<rustls::ClientConnection, TcpStream>,
1157    ) -> (NfsTransport, bool) {
1158        use rustls::StreamOwned;
1159        use std::os::unix::io::AsRawFd;
1160
1161        // 1. Drive the TLS 1.3 handshake to completion (normally lazy).
1162        if let Err(e) = tls_stream.conn.complete_io(&mut tls_stream.sock) {
1163            debug!("NFS TLS: kTLS skipped  --  handshake completion failed ({e})");
1164            return (NfsTransport::Tls(Box::new(tls_stream)), false);
1165        }
1166
1167        let fd = tls_stream.sock.as_raw_fd();
1168
1169        // 2. Enable TLS ULP on the socket (non-destructive probe).
1170        //    Fails with ENOENT when CONFIG_TLS is absent.
1171        const SOL_TCP: libc::c_int = 6;
1172        const TCP_ULP_OPT: libc::c_int = 31;
1173        let ret = unsafe {
1174            libc::setsockopt(fd, SOL_TCP, TCP_ULP_OPT, b"tls\0".as_ptr().cast(), 4)
1175        };
1176        if ret < 0 {
1177            let err = std::io::Error::last_os_error();
1178            debug!("NFS TLS: kTLS ULP unavailable ({err}), using userspace TLS");
1179            return (NfsTransport::Tls(Box::new(tls_stream)), false);
1180        }
1181
1182        // 3. Record cipher suite before consuming the connection.
1183        let suite = match tls_stream.conn.negotiated_cipher_suite() {
1184            Some(s) => s,
1185            None => {
1186                debug!("NFS TLS: kTLS skipped  --  no negotiated cipher suite");
1187                return (NfsTransport::Tls(Box::new(tls_stream)), false);
1188            }
1189        };
1190
1191        // -- POINT OF NO RETURN --
1192        // `dangerous_extract_secrets()` consumes `ClientConnection`.
1193        // After this, we cannot fall back to userspace TLS.
1194        let StreamOwned { conn, sock: tcp } = tls_stream;
1195
1196        let secrets = match conn.dangerous_extract_secrets() {
1197            Ok(s) => s,
1198            Err(e) => {
1199                debug!("NFS TLS: kTLS secret extraction failed ({e}), degraded to plain TCP");
1200                return (NfsTransport::Tcp(tcp), false);
1201            }
1202        };
1203
1204        // 4. Build kernel crypto info from rustls secrets.
1205        let tx_info = match ktls::CryptoInfo::from_rustls(suite, secrets.tx) {
1206            Ok(i) => i,
1207            Err(e) => {
1208                debug!("NFS TLS: kTLS TX crypto unsupported ({e:?}), degraded to plain TCP");
1209                return (NfsTransport::Tcp(tcp), false);
1210            }
1211        };
1212        let rx_info = match ktls::CryptoInfo::from_rustls(suite, secrets.rx) {
1213            Ok(i) => i,
1214            Err(e) => {
1215                debug!("NFS TLS: kTLS RX crypto unsupported ({e:?}), degraded to plain TCP");
1216                return (NfsTransport::Tcp(tcp), false);
1217            }
1218        };
1219
1220        // 5. Configure TX and RX on the socket fd.
1221        const SOL_TLS: libc::c_int = 282;
1222        const TLS_TX: libc::c_int = 1;
1223        const TLS_RX: libc::c_int = 2;
1224
1225        let tx_ret = unsafe {
1226            libc::setsockopt(
1227                fd, SOL_TLS, TLS_TX,
1228                tx_info.as_ptr(), tx_info.size() as libc::socklen_t,
1229            )
1230        };
1231        if tx_ret < 0 {
1232            let err = std::io::Error::last_os_error();
1233            debug!("NFS TLS: kTLS TX setsockopt failed ({err}), degraded to plain TCP");
1234            return (NfsTransport::Tcp(tcp), false);
1235        }
1236
1237        let rx_ret = unsafe {
1238            libc::setsockopt(
1239                fd, SOL_TLS, TLS_RX,
1240                rx_info.as_ptr(), rx_info.size() as libc::socklen_t,
1241            )
1242        };
1243        if rx_ret < 0 {
1244            let err = std::io::Error::last_os_error();
1245            debug!("NFS TLS: kTLS RX setsockopt failed ({err}), TX active but RX not  --  degraded");
1246            return (NfsTransport::Tcp(tcp), false);
1247        }
1248
1249        (NfsTransport::Ktls(tcp), true)
1250    }
1251
1252    fn next_xid(&self) -> u32 {
1253        self.xid_counter.fetch_add(1, Ordering::Relaxed)
1254    }
1255
1256    fn next_sequence_id(&self) -> u32 {
1257        self.sequence_id.fetch_add(1, Ordering::Relaxed)
1258    }
1259
1260    /// Send an RPC message body and receive the reply.
1261    ///
1262    /// `body` is the XDR-encoded RPC call **without** the 4-byte record mark.
1263    /// For TCP/TLS/kTLS transports this method prepends the record-mark frame
1264    /// (last-fragment bit + length).  The future `NfsTransport::Rdma` variant
1265    /// will use RFC 8166 `rdma_msg` framing instead.
1266    ///
1267    /// `_xid` is used by the RDMA transport (Wave 2) where the transaction ID
1268    /// appears in the RFC 8166 rdma_msg header separately from the XDR body.
1269    fn rpc_call(&mut self, _xid: u32, body: &[u8]) -> Result<Vec<u8>, NfsError> {
1270        // RDMA early-return: bypass TCP record-mark framing entirely.
1271        // RdmaConnection::rpc_call() builds the rdma_msg header and does
1272        // RDMA Send/Recv instead of stream write/read.
1273        #[cfg(feature = "rdma")]
1274        if let NfsTransport::Rdma(ref mut rdma) = self.stream {
1275            return rdma.rpc_call(_xid, body);
1276        }
1277
1278        let frame = rpc::frame_rpc_message(body);
1279        self.stream.write_all(&frame)?;
1280        self.stream.flush()?;
1281        self.read_reply()
1282    }
1283
1284    /// Write a single file to the NFS server via compound RPC.
1285    ///
1286    /// SEQUENCE + PUTFH(parent) + OPEN(create) + WRITE(FILE_SYNC) + CLOSE
1287    /// Retries on NFS4ERR_GRACE (server grace period after session creation).
1288    pub fn write_file(
1289        &mut self,
1290        parent_handle: &[u8],
1291        filename: &str,
1292        data: &[u8],
1293        mode: u32,
1294        uid: u32,
1295        gid: u32,
1296        mtime: (i64, i64),
1297    ) -> Result<(), NfsError> {
1298        for attempt in 0..constants::NFS_GRACE_RETRY_COUNT {
1299            match self.write_file_inner(parent_handle, filename, data, mode, uid, gid, mtime) {
1300                Ok(()) => return Ok(()),
1301                Err(NfsError::Nfs4Error { code: 10013, .. }) => {
1302                    // NFS4ERR_GRACE  --  server in grace period, retry after delay
1303                    if attempt < constants::NFS_GRACE_RETRY_COUNT - 1 {
1304                        debug!("NFS bypass: server in grace period, retry {} in {}ms", attempt + 1, constants::NFS_RECONNECT_SLEEP_MS);
1305                        std::thread::sleep(std::time::Duration::from_millis(constants::NFS_RECONNECT_SLEEP_MS));
1306                        continue;
1307                    }
1308                    return Err(NfsError::Nfs4Error {
1309                        code: 10013,
1310                        message: "server grace period persists after retries".into(),
1311                    });
1312                }
1313                Err(e) => return Err(e),
1314            }
1315        }
1316        Err(NfsError::Nfs4Error {
1317            code: 10013,
1318            message: "server grace period persists after retries".into(),
1319        })
1320    }
1321
1322    fn write_file_inner(
1323        &mut self,
1324        parent_handle: &[u8],
1325        filename: &str,
1326        data: &[u8],
1327        mode: u32,
1328        _uid: u32,
1329        _gid: u32,
1330        _mtime: (i64, i64),
1331    ) -> Result<(), NfsError> {
1332        #[cfg(feature = "krb5")]
1333        if let Some(ref gss) = self.gss_ctx {
1334            if gss.rpc_gss_service() == super::gss::RpcGssService::Privacy
1335                && data.len() > constants::KRB5P_MAX_WRITE_DATA
1336            {
1337                return Err(NfsError::Nfs4Error {
1338                    code: rpc::NFS4ERR_INVAL,
1339                    message: format!(
1340                        "krb5p bypass: {}B exceeds {}B threshold (AES block boundary at priv_bytes=3952)",
1341                        data.len(), constants::KRB5P_MAX_WRITE_DATA
1342                    ),
1343                });
1344            }
1345        }
1346
1347        let xid = self.next_xid();
1348        let seq_id = self.next_sequence_id();
1349
1350        let ops = vec![
1351            Nfs4Op::Sequence {
1352                session_id: self.session_id,
1353                sequence_id: seq_id,
1354                slot_id: 0,
1355                highest_slot_id: 0,
1356                cache_this: false,
1357            },
1358            Nfs4Op::PutFh { handle: parent_handle.to_vec() },
1359            Nfs4Op::Open {
1360                seqid: 0,
1361                share_access: rpc::OPEN4_SHARE_ACCESS_WRITE,
1362                share_deny: rpc::OPEN4_SHARE_DENY_NONE,
1363                clientid: self.client_id,
1364                owner: format!("foxing-{}", std::process::id()).into_bytes(),
1365                filename: filename.to_string(),
1366                mode,
1367                create_owner: None,
1368                create_group: None,
1369                create_mtime: None,
1370            },
1371            Nfs4Op::Write {
1372                stateid: StateId::current(),
1373                offset: 0,
1374                stable: WriteStable::FileSync,
1375                data: data.to_vec(),
1376            },
1377            Nfs4Op::Close {
1378                seqid: 1,
1379                stateid: StateId::current(),
1380            },
1381        ];
1382
1383        let msg = self.build_compound_msg(xid, "fxcp", &ops)?;
1384
1385        debug!("NFS write compound: {} bytes, {} ops, handle={} bytes, data={} bytes",
1386               msg.len(), ops.len(), parent_handle.len(), data.len());
1387        if msg.len() > 140 {
1388            debug!("NFS compound hex (first 200 bytes): {:02x?}", &msg[..msg.len().min(200)]);
1389        }
1390
1391        let reply_data = self.rpc_call(xid, &msg)?;
1392        let reply_data = self.unwrap_reply(reply_data)?;
1393        let reply = rpc::parse_compound_reply(&reply_data)?;
1394
1395        // Log detailed reply info
1396        debug!("NFS compound reply: overall_status={} ({}) ops={}",
1397               reply.status, rpc::nfs4_error_name(reply.status), reply.op_results.len());
1398        for (i, r) in reply.op_results.iter().enumerate() {
1399            debug!("  op[{}]: op={} status={} ({})", i, r.op, r.status, rpc::nfs4_error_name(r.status));
1400        }
1401
1402        if reply.status != rpc::NFS4_OK {
1403            let code = reply.status;
1404            // Check for recoverable errors
1405            if code == rpc::NFS4ERR_BADSESSION || code == rpc::NFS4ERR_BADSEQ {
1406                return Err(NfsError::Nfs4Error {
1407                    code,
1408                    message: format!("session error: {}  --  needs recovery", rpc::nfs4_error_name(code)),
1409                });
1410            }
1411            if code == rpc::NFS4ERR_STALE {
1412                return Err(NfsError::StaleHandle {
1413                    path: filename.to_string(),
1414                });
1415            }
1416            return Err(NfsError::Nfs4Error {
1417                code,
1418                message: format!("compound failed: {}", rpc::nfs4_error_name(code)),
1419            });
1420        }
1421
1422        // Check individual op statuses
1423        for result in &reply.op_results {
1424            if result.status != rpc::NFS4_OK {
1425                return Err(NfsError::Nfs4Error {
1426                    code: result.status,
1427                    message: format!("op {} failed: {}", result.op, rpc::nfs4_error_name(result.status)),
1428                });
1429            }
1430        }
1431
1432        debug!("NFS bypass: wrote {} ({} bytes) via compound RPC", filename, data.len());
1433        Ok(())
1434    }
1435
1436    /// Write multiple files to the NFS server in a single compound RPC.
1437    ///
1438    /// Builds: SEQUENCE + PUTFH(parent) + [OPEN+WRITE+CLOSE]xN
1439    /// Returns `Vec<Result<(), NfsError>>`  --  per-file results for partial failure handling.
1440    ///
1441    /// Max 4 files per compound (SEQUENCE + PUTFH + 4x(OPEN+WRITE+CLOSE) = 14 ops,
1442    /// within `ca_maxoperations=16` negotiated in CREATE_SESSION). Larger batches are
1443    /// automatically split into multiple compounds.
1444    pub fn write_files_compound(
1445        &mut self,
1446        parent_handle: &[u8],
1447        files: &[(&str, &[u8], u32, u32, u32, (i64, i64))],  // (filename, data, mode, uid, gid, mtime)
1448    ) -> Result<Vec<Result<(), NfsError>>, NfsError> {
1449        if files.is_empty() { return Ok(vec![]); }
1450
1451        // SEQUENCE(1) + PUTFH(1) + N*(OPEN+WRITE+CLOSE=3) = 2 + 3N
1452        // AIMD tuner grows from 3 toward (ca_maxops-2)/3 after sustained success.
1453        let max_files = self.compound_tuner.current();
1454        // Byte-size gate: under krb5p privacy, compound_args + 4 (seq_num) must stay
1455        // under 3,952 bytes (the AES block boundary where kernel gss_unwrap breaks).
1456        // Under AUTH_SYS/krb5/krb5i, use ~800KB (80% of typical svc_max_payload).
1457        #[cfg(feature = "krb5")]
1458        let compound_byte_limit: usize = if self.gss_ctx.as_ref()
1459            .map(|g| g.rpc_gss_service() == super::gss::RpcGssService::Privacy)
1460            .unwrap_or(false)
1461        {
1462            constants::KRB5P_MAX_WRITE_DATA + constants::GSS_WRAP_OVERHEAD_BYTES
1463        } else {
1464            constants::NFS_COMPOUND_BYTE_LIMIT
1465        };
1466        #[cfg(not(feature = "krb5"))]
1467        let compound_byte_limit: usize = constants::NFS_COMPOUND_BYTE_LIMIT;
1468
1469        let effective_max = {
1470            let mut byte_acc: usize = constants::NFS_XDR_HEADER_OVERHEAD;
1471            let mut count = 0usize;
1472            for (_, data, ..) in files.iter() {
1473                byte_acc += data.len() + constants::NFS_XDR_PER_OP_OVERHEAD;
1474                if byte_acc > compound_byte_limit {
1475                    break;
1476                }
1477                count += 1;
1478            }
1479            count.max(1).min(max_files)
1480        };
1481        if files.len() > effective_max {
1482            let mut all_results = Vec::with_capacity(files.len());
1483            for chunk in files.chunks(effective_max) {
1484                let results = self.write_files_compound(parent_handle, chunk)?;
1485                all_results.extend(results);
1486            }
1487            return Ok(all_results);
1488        }
1489
1490        let xid = self.next_xid();
1491        let seq_id = self.next_sequence_id();
1492
1493        let mut ops = vec![
1494            Nfs4Op::Sequence {
1495                session_id: self.session_id,
1496                sequence_id: seq_id,
1497                slot_id: 0,
1498                highest_slot_id: 0,
1499                cache_this: false,
1500            },
1501            Nfs4Op::PutFh { handle: parent_handle.to_vec() },
1502        ];
1503
1504        let owner = format!("foxing-{}", std::process::id()).into_bytes();
1505        for (filename, data, mode, _uid, _gid, _mtime) in files {
1506            ops.push(Nfs4Op::Open {
1507                seqid: 0,
1508                share_access: rpc::OPEN4_SHARE_ACCESS_WRITE,
1509                share_deny: rpc::OPEN4_SHARE_DENY_NONE,
1510                clientid: self.client_id,
1511                owner: owner.clone(),
1512                filename: filename.to_string(),
1513                mode: *mode,
1514                create_owner: None,
1515                create_group: None,
1516                create_mtime: None,
1517            });
1518            ops.push(Nfs4Op::Write {
1519                stateid: StateId::current(),
1520                offset: 0,
1521                stable: WriteStable::FileSync,
1522                data: data.to_vec(),
1523            });
1524            ops.push(Nfs4Op::Close {
1525                seqid: 1,
1526                stateid: StateId::current(),
1527            });
1528        }
1529
1530        let msg = self.build_compound_msg(xid, "mwrt", &ops)?;
1531        let reply_data = self.rpc_call(xid, &msg)?;
1532        let reply_data = self.unwrap_reply(reply_data)?;
1533        let reply = rpc::parse_compound_reply(&reply_data)?;
1534
1535        // Parse per-file results from compound reply.
1536        // Layout: [0]=SEQUENCE, [1]=PUTFH, then groups of 3 ops per file:
1537        //   [2+i*3]=OPEN, [3+i*3]=WRITE, [4+i*3]=CLOSE
1538        let mut results = Vec::with_capacity(files.len());
1539        for file_idx in 0..files.len() {
1540            let open_idx = 2 + file_idx * 3;
1541            let mut file_ok = true;
1542            for op_offset in 0..3 {
1543                let idx = open_idx + op_offset;
1544                match reply.op_results.get(idx) {
1545                    Some(r) if r.status == rpc::NFS4_OK => {}
1546                    Some(r) => {
1547                        file_ok = false;
1548                        // NFS4ERR_REQ_TOO_BIG (10019) / NFS4ERR_REP_TOO_BIG (10028)
1549                        if r.status == 10019 || r.status == 10028 {
1550                            self.compound_tuner.on_size_error();
1551                        }
1552                        results.push(Err(NfsError::Nfs4Error {
1553                            code: r.status,
1554                            message: format!(
1555                                "multi-write file {} op {} failed: {}",
1556                                file_idx, r.op, rpc::nfs4_error_name(r.status)
1557                            ),
1558                        }));
1559                        break;
1560                    }
1561                    None => {
1562                        file_ok = false;
1563                        results.push(Err(NfsError::Nfs4Error {
1564                            code: reply.status,
1565                            message: format!(
1566                                "multi-write file {} truncated compound (prior error)",
1567                                file_idx
1568                            ),
1569                        }));
1570                        break;
1571                    }
1572                }
1573            }
1574            if file_ok {
1575                debug!("NFS multi-write: file {} ({}) ok", file_idx, files[file_idx].0);
1576                results.push(Ok(()));
1577            }
1578        }
1579        if results.iter().all(|r| r.is_ok()) {
1580            self.compound_tuner.on_success();
1581        }
1582        Ok(results)
1583    }
1584
1585    /// Read a complete RPC reply (record-mark framed) from the TCP stream.
1586    /// Handles multi-fragment replies by reading until the last-fragment bit is set.
1587    fn read_reply(&mut self) -> Result<Vec<u8>, NfsError> {
1588        set_quickack(&self.stream);
1589        let mut result = Vec::with_capacity(constants::NFS_REPLY_INITIAL_CAPACITY);
1590
1591        loop {
1592            let mut rm_buf = [0u8; 4];
1593            self.stream.read_exact(&mut rm_buf)?;
1594            let rm = u32::from_be_bytes(rm_buf);
1595            let last_fragment = (rm & 0x80000000) != 0;
1596            let length = (rm & 0x7FFFFFFF) as usize;
1597
1598            if length > crate::constants::NFS_MAX_REQUEST_SIZE {
1599                return Err(NfsError::RpcError(format!("reply fragment too large: {} bytes", length)));
1600            }
1601
1602            if result.is_empty() {
1603                // First fragment  --  include the record mark for the parser
1604                result.extend_from_slice(&rm_buf);
1605            }
1606
1607            let offset = result.len();
1608            result.resize(offset + length, 0);
1609            self.stream.read_exact(&mut result[offset..])?;
1610
1611            if last_fragment {
1612                break;
1613            }
1614        }
1615
1616        Ok(result)
1617    }
1618
1619    /// Get or resolve the NFS file handle for a directory path.
1620    ///
1621    /// Uses PUTROOTFH + LOOKUP compounds to walk from the export root to
1622    /// the target directory, caching intermediate handles.
1623    pub fn get_or_resolve_handle(&mut self, dir_path: &Path) -> Result<Vec<u8>, NfsError> {
1624        if let Some(cached) = self.dir_handle_cache.get(dir_path) {
1625            return Ok(cached.clone());
1626        }
1627
1628        // Compute relative path from mount point
1629        let rel = dir_path.strip_prefix(&self.server_info.mount_point)
1630            .unwrap_or(std::path::Path::new(""));
1631
1632        // Walk from export root via PUTROOTFH + LOOKUP chain + GETFH
1633        let handle = self.resolve_via_lookup(rel)?;
1634
1635        self.dir_handle_cache.insert(dir_path.to_path_buf(), handle.clone());
1636        Ok(handle)
1637    }
1638
1639    /// Resolve a relative path from the export root using PUTFH + LOOKUP + GETFH.
1640    fn resolve_via_lookup(&mut self, rel_path: &Path) -> Result<Vec<u8>, NfsError> {
1641        let xid = self.next_xid();
1642        let seq_id = self.next_sequence_id();
1643
1644        // Get the mount point's NFS wire filehandle via name_to_handle_at.
1645        // The kernel stores the server's opaque handle; we extract the wire
1646        // portion (skipping the kernel's internal 14-byte header).
1647        let mount_fh = super::mount::resolve_nfs_handle(&self.server_info.mount_point)
1648            .map_err(|e| NfsError::StaleHandle { path: format!("mount: {}", e) })?;
1649        debug!("NFS PUTFH: mount handle {} bytes: {:02x?}", mount_fh.len(), &mount_fh[..mount_fh.len().min(16)]);
1650
1651        let mut ops = vec![
1652            Nfs4Op::Sequence {
1653                session_id: self.session_id,
1654                sequence_id: seq_id,
1655                slot_id: 0,
1656                highest_slot_id: 0,
1657                cache_this: false,
1658            },
1659            Nfs4Op::PutFh { handle: mount_fh },
1660        ];
1661
1662        // LOOKUP only the relative path within the export
1663        for component in rel_path.components() {
1664            if let std::path::Component::Normal(name) = component {
1665                ops.push(Nfs4Op::Lookup { name: name.to_string_lossy().into_owned() });
1666            }
1667        }
1668
1669        // GETFH to retrieve the actual server-side filehandle
1670        ops.push(Nfs4Op::GetFh);
1671
1672        let msg = self.build_compound_msg(xid, "lkup", &ops)?;
1673        let reply_data = self.rpc_call(xid, &msg)?;
1674        let reply_data = self.unwrap_reply(reply_data)?;
1675        let reply = rpc::parse_compound_reply(&reply_data)?;
1676
1677        debug!("NFS LOOKUP reply: overall_status={} ({}) ops={}",
1678               reply.status, rpc::nfs4_error_name(reply.status), reply.op_results.len());
1679        for (i, r) in reply.op_results.iter().enumerate() {
1680            debug!("  LOOKUP op[{}]: op={} status={} ({})", i, r.op, r.status, rpc::nfs4_error_name(r.status));
1681        }
1682
1683        if reply.status != rpc::NFS4_OK {
1684            return Err(NfsError::Nfs4Error {
1685                code: reply.status,
1686                message: format!("LOOKUP for {:?} failed: {}", rel_path, rpc::nfs4_error_name(reply.status)),
1687            });
1688        }
1689
1690        // Extract the filehandle from GETFH result
1691        for result in &reply.op_results {
1692            if result.op == rpc::OP_GETFH
1693                && let Some(fh) = &result.filehandle {
1694                    debug!("NFS GETFH: resolved {:?} -> {} bytes", rel_path, fh.len());
1695                    return Ok(fh.clone());
1696                }
1697        }
1698
1699        Err(NfsError::SessionFailed(format!("GETFH not found in reply for {:?}", rel_path)))
1700    }
1701
1702    /// Invalidate a cached directory handle.
1703    pub fn invalidate_handle(&self, dir_path: &Path) {
1704        self.dir_handle_cache.remove(dir_path);
1705    }
1706
1707    /// Batch-fetch size+mtime for multiple files in a directory.
1708    ///
1709    /// Sends a single compound: SEQUENCE + PUTFH(dir) + [LOOKUP(file) + GETATTR]xN
1710    /// Returns (filename, size, mtime_sec, mtime_nsec) for each file found.
1711    /// Files that don't exist (LOOKUP returns NFS4ERR_NOENT) are silently skipped.
1712    /// Max 7 files per compound (SEQUENCE + PUTFH + 7x(LOOKUP+GETATTR) = 16 ops).
1713    pub fn batch_stat(
1714        &mut self,
1715        dir_handle: &[u8],
1716        filenames: &[&str],
1717    ) -> Result<Vec<(String, u64, i64, i64)>, NfsError> {
1718        let xid = self.next_xid();
1719        let seq_id = self.next_sequence_id();
1720
1721        let attr_request = [1 << rpc::FATTR4_SIZE, 1 << (rpc::FATTR4_TIME_MODIFY - 32)];
1722
1723        let mut ops = vec![
1724            rpc::Nfs4Op::Sequence {
1725                session_id: self.session_id,
1726                sequence_id: seq_id,
1727                slot_id: 0,
1728                highest_slot_id: 0,
1729                cache_this: false,
1730            },
1731            rpc::Nfs4Op::PutFh { handle: dir_handle.to_vec() },
1732        ];
1733
1734        for name in filenames {
1735            ops.push(rpc::Nfs4Op::Lookup { name: name.to_string() });
1736            ops.push(rpc::Nfs4Op::GetAttr { attr_request });
1737        }
1738
1739        let msg = self.build_compound_msg(xid, "bstat", &ops)?;
1740        let reply_data = self.rpc_call(xid, &msg)?;
1741        let reply_data = self.unwrap_reply(reply_data)?;
1742        let reply = rpc::parse_compound_reply(&reply_data)?;
1743
1744        // Extract results: for each LOOKUP+GETATTR pair, check status
1745        let mut results = Vec::new();
1746        for (i, name) in filenames.iter().enumerate() {
1747            // Find the LOOKUP result for this file (skip SEQUENCE + PUTFH = first 2 ops)
1748            let lookup_idx = 2 + i * 2;
1749            let getattr_idx = lookup_idx + 1;
1750
1751            // Check LOOKUP status
1752            if let Some(lookup_result) = reply.op_results.get(lookup_idx) {
1753                if lookup_result.status != rpc::NFS4_OK {
1754                    continue; // file doesn't exist or LOOKUP failed
1755                }
1756            } else {
1757                break; // compound stopped processing (prior error)
1758            }
1759
1760            // Check GETATTR result
1761            if let Some(getattr_result) = reply.op_results.get(getattr_idx) {
1762                if getattr_result.status == rpc::NFS4_OK
1763                    && let (Some(size), Some((mtime_s, mtime_ns))) = (getattr_result.size, getattr_result.mtime) {
1764                        results.push((name.to_string(), size, mtime_s, mtime_ns));
1765                    }
1766            } else {
1767                break;
1768            }
1769        }
1770
1771        Ok(results)
1772    }
1773
1774    /// Batch-read the first `read_bytes` of multiple files in a single compound RPC.
1775    ///
1776    /// Compound structure: SEQUENCE + PUTFH(dir) + [LOOKUP(name) + READ(offset=0, count=read_bytes)] × N
1777    /// Uses the anonymous stateid (seqid=0, other=all-zeros) — no OPEN/CLOSE needed.
1778    /// Files where LOOKUP returns NFS4ERR_NOENT are silently skipped.
1779    ///
1780    /// Returns: Vec of (filename, head_bytes) for files that were successfully read.
1781    pub fn batch_read_head(
1782        &mut self,
1783        dir_handle: &[u8],
1784        filenames: &[&str],
1785        read_bytes: usize,
1786    ) -> Result<Vec<(String, Vec<u8>)>, NfsError> {
1787        if filenames.is_empty() {
1788            return Ok(Vec::new());
1789        }
1790
1791        let xid = self.next_xid();
1792        let seq_id = self.next_sequence_id();
1793
1794        let mut ops = Vec::with_capacity(2 + filenames.len() * 2);
1795        ops.push(rpc::Nfs4Op::Sequence {
1796            session_id: self.session_id,
1797            sequence_id: seq_id,
1798            slot_id: 0,
1799            highest_slot_id: 0,
1800            cache_this: false,
1801        });
1802        ops.push(rpc::Nfs4Op::PutFh { handle: dir_handle.to_vec() });
1803
1804        for name in filenames {
1805            ops.push(rpc::Nfs4Op::Lookup { name: name.to_string() });
1806            ops.push(rpc::Nfs4Op::Read {
1807                stateid: rpc::StateId::default(),
1808                offset: 0,
1809                count: read_bytes as u32,
1810            });
1811        }
1812
1813        let msg = self.build_compound_msg(xid, "brdh", &ops)?;
1814        let reply_data = self.rpc_call(xid, &msg)?;
1815        let reply_data = self.unwrap_reply(reply_data)?;
1816        let reply = rpc::parse_compound_reply(&reply_data)?;
1817
1818        let mut out = Vec::new();
1819        for (i, name) in filenames.iter().enumerate() {
1820            let lookup_idx = 2 + i * 2;
1821            let read_idx = lookup_idx + 1;
1822
1823            // Check LOOKUP status
1824            if let Some(lookup_result) = reply.op_results.get(lookup_idx) {
1825                if lookup_result.status != rpc::NFS4_OK {
1826                    continue; // file not found or error — skip silently
1827                }
1828            } else {
1829                break; // compound stopped processing (prior error)
1830            }
1831
1832            // Check READ result
1833            if let Some(read_result) = reply.op_results.get(read_idx) {
1834                if read_result.status == rpc::NFS4_OK
1835                    && let Some(ref data) = read_result.data {
1836                        out.push((name.to_string(), data.clone()));
1837                    }
1838            } else {
1839                break;
1840            }
1841        }
1842
1843        Ok(out)
1844    }
1845}
1846
1847/// Pool of NFS compound RPC sessions for parallel writes.
1848///
1849/// Each session has its own TCP connection and NFSv4.1 session ID,
1850/// enabling true parallel RPCs to the server (one in-flight per session).
1851/// Rayon threads grab sessions by index (round-robin) to avoid contention.
1852pub struct NfsClientPool {
1853    clients: Vec<parking_lot::Mutex<NfsCompoundClient>>,
1854}
1855
1856impl NfsClientPool {
1857    /// Create a pool of `pool_size` independent NFS sessions.
1858    pub fn new(info: &NfsBypassInfo, pool_size: usize) -> Result<Self, NfsError> {
1859        let pool_size = pool_size.max(1);
1860        let mut clients = Vec::with_capacity(pool_size);
1861        for i in 0..pool_size {
1862            match NfsCompoundClient::connect(info) {
1863                Ok(client) => {
1864                    clients.push(parking_lot::Mutex::new(client));
1865                    #[cfg(feature = "tls")]
1866                    {
1867                        use super::NfsTransportSecurity;
1868                        if info.transport_security != NfsTransportSecurity::None {
1869                            info!("NFS pool: session {} TLS active", i);
1870                        }
1871                    }
1872                }
1873                Err(e) => {
1874                    if i == 0 {
1875                        return Err(e); // First session must succeed
1876                    }
1877                    info!("NFS pool: session {} failed ({}), pool size = {}", i, e, clients.len());
1878                    break; // Use whatever we got
1879                }
1880            }
1881        }
1882        info!("NFS pool: {} sessions established to {}", clients.len(), info.server_addr);
1883        Ok(Self { clients })
1884    }
1885
1886    /// Get a session by index (round-robin across pool).
1887    pub fn get(&self, idx: usize) -> &parking_lot::Mutex<NfsCompoundClient> {
1888        &self.clients[idx % self.clients.len()]
1889    }
1890
1891    /// Pool size (number of active sessions).
1892    pub fn len(&self) -> usize {
1893        self.clients.len()
1894    }
1895
1896    /// Returns the [`NfsBypassInfo`] from the first session in the pool.
1897    pub fn bypass_info(&self) -> NfsBypassInfo {
1898        self.clients
1899            .first()
1900            .map(|s| s.lock().server_info.clone())
1901            .unwrap_or_default()
1902    }
1903}
1904
1905/// Fast NFS server liveness check via NULL RPC.
1906///
1907/// Opens a fresh TCP connection to the NFS server and sends a NULL procedure
1908/// call. Returns true if the server responds within 2 seconds. Does not
1909/// touch any session or cached state  --  safe to call from the health probe.
1910pub fn probe_server_alive(server_addr: &std::net::SocketAddr) -> bool {
1911    use std::io::{Read, Write};
1912    let mut stream = match std::net::TcpStream::connect_timeout(server_addr, std::time::Duration::from_secs(constants::NFS_PROBE_TIMEOUT_SECS)) {
1913        Ok(s) => s,
1914        Err(_) => return false,
1915    };
1916    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(constants::NFS_PROBE_TIMEOUT_SECS)));
1917    let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(constants::NFS_PROBE_TIMEOUT_SECS)));
1918    let msg = rpc::frame_rpc_message(&rpc::build_null_call(1));
1919    if stream.write_all(&msg).is_err() { return false; }
1920    if stream.flush().is_err() { return false; }
1921    // NULL reply is just an RPC reply header  --  reading any bytes means success
1922    let mut rm_buf = [0u8; 4];
1923    stream.read_exact(&mut rm_buf).is_ok()
1924}
1925
1926/// Encode AUTH_SYS credentials.
1927fn encode_auth_sys(enc: &mut super::xdr::XdrEncoder, uid: u32, gid: u32, machine: &str) {
1928    enc.encode_u32(rpc::AUTH_SYS);
1929    let mut body = super::xdr::XdrEncoder::new(64);
1930    body.encode_u32(0);          // stamp
1931    body.encode_string(machine);
1932    body.encode_u32(uid);
1933    body.encode_u32(gid);
1934    body.encode_u32(1);          // gids count
1935    body.encode_u32(gid);        // gids[0]
1936    let body_bytes = body.into_bytes();
1937    enc.encode_opaque(&body_bytes);
1938}
1939
1940#[cfg(test)]
1941mod tests {
1942    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1943    #[cfg(feature = "tls")]
1944    #[test]
1945    fn tls_alpn_is_sunrpc() {
1946        // RFC 9289 S7.2: ALPN protocol identifier MUST be "sunrpc"
1947        assert_eq!(crate::constants::NFS_TLS_ALPN, b"sunrpc");
1948        assert_eq!(crate::constants::NFS_TLS_ALPN.len(), 6);
1949    }
1950}