Skip to main content

fxcp_core/nfs/
rpc.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/rpc.rs  --  NFSv4.2 compound RPC builder and reply parser
5
6//! Constructs NFSv4.2 compound RPC payloads and parses server replies.
7//!
8//! Implements the minimal ONC RPC + NFSv4.2 wire format needed for
9//! atomic file creation via compound operations.
10
11use super::xdr::{XdrEncoder, XdrDecoder};
12use super::NfsError;
13use crate::constants;
14use tracing::debug;
15
16/// NFSv4.2 SEQUENCE operation code (RFC 8881 S18.46).
17pub const OP_SEQUENCE: u32 = 53;
18/// NFSv4 PUTFH operation code: set the current filehandle (RFC 8881 S18.19).
19pub const OP_PUTFH: u32 = 22;
20/// NFSv4 OPEN operation code: open or create a file (RFC 8881 S18.16).
21pub const OP_OPEN: u32 = 18;
22/// NFSv4 WRITE operation code: write data to a file (RFC 8881 S18.32).
23pub const OP_WRITE: u32 = 38;
24/// NFSv4 READ operation code: read data from a file (RFC 7530 S16.23).
25pub const OP_READ: u32 = 25;
26/// NFSv4 SETATTR operation code: set file attributes (RFC 8881 S18.30).
27pub const OP_SETATTR: u32 = 34;
28/// NFSv4 CLOSE operation code: release an open stateid (RFC 8881 S18.2).
29pub const OP_CLOSE: u32 = 4;
30/// NFSv4 GETATTR operation code: retrieve file attributes (RFC 8881 S18.7).
31pub const OP_GETATTR: u32 = 9;
32/// NFSv4.1 EXCHANGE_ID operation code: establish client identity (RFC 8881 S18.35).
33pub const OP_EXCHANGE_ID: u32 = 42;
34/// NFSv4.1 CREATE_SESSION operation code: create a session (RFC 8881 S18.36).
35pub const OP_CREATE_SESSION: u32 = 43;
36/// NFSv4.1 DESTROY_SESSION operation code: tear down a session (RFC 8881 S18.37).
37pub const OP_DESTROY_SESSION: u32 = 44;
38/// NFSv4 PUTROOTFH operation code: set the current filehandle to the root (RFC 8881 S18.20).
39pub const OP_PUTROOTFH: u32 = 24;
40/// NFSv4 LOOKUP operation code: look up a name in a directory (RFC 8881 S18.13).
41pub const OP_LOOKUP: u32 = 15;
42/// NFSv4 GETFH operation code: retrieve the current filehandle (RFC 8881 S18.8).
43pub const OP_GETFH: u32 = 10;
44/// NFSv4.1 RECLAIM_COMPLETE operation code: end grace period (RFC 8881 S18.51).
45pub const OP_RECLAIM_COMPLETE: u32 = 58;
46
47/// NFS4 status: operation succeeded.
48pub const NFS4_OK: u32 = 0;
49/// NFS4 error: stale filehandle (target was deleted or renamed).
50pub const NFS4ERR_STALE: u32 = 70;
51/// NFS4 error: file or directory not found.
52pub const NFS4ERR_NOENT: u32 = 2;
53/// NFS4 error: file already exists.
54pub const NFS4ERR_EXIST: u32 = 17;
55/// NFS4 error: permission denied.
56pub const NFS4ERR_ACCESS: u32 = 13;
57/// NFS4 error: invalid argument (e.g. bad attribute value, nseconds out of range).
58pub const NFS4ERR_INVAL: u32 = 22;
59/// NFS4 error: not a directory.
60pub const NFS4ERR_NOTDIR: u32 = 20;
61/// NFS4 error: is a directory (write to directory attempted).
62pub const NFS4ERR_ISDIR: u32 = 21;
63/// NFS4 error: file too large.
64pub const NFS4ERR_FBIG: u32 = 27;
65/// NFS4 error: no space on device.
66pub const NFS4ERR_NOSPC: u32 = 28;
67/// NFS4 error: read-only filesystem.
68pub const NFS4ERR_ROFS: u32 = 30;
69/// NFS4 error: filename too long.
70pub const NFS4ERR_NAMETOOLONG: u32 = 63;
71/// NFS4 error: directory not empty.
72pub const NFS4ERR_NOTEMPTY: u32 = 66;
73/// NFS4 error: server is busy, client should retry.
74pub const NFS4ERR_DELAY: u32 = 10008;
75/// NFS4 error: bad stateid (expired or wrong open).
76pub const NFS4ERR_BAD_STATEID: u32 = 10025;
77/// NFS4 error: session ID is invalid or expired.
78pub const NFS4ERR_BADSESSION: u32 = 10052;
79/// NFS4 error: bad sequence ID in a stateful operation.
80pub const NFS4ERR_BADSEQ: u32 = 10026;
81/// NFS4 error: sequence operations arrived out of order.
82pub const NFS4ERR_SEQ_MISORDERED: u32 = 10063;
83
84/// ONC RPC protocol version (always 2).
85pub const RPC_VERSION: u32 = 2;
86/// ONC RPC program number for NFS.
87pub const NFS_PROGRAM: u32 = 100003;
88/// NFS protocol major version (4).
89pub const NFS_V4: u32 = 4;
90/// NFS procedure number for COMPOUND (batched operations).
91pub const NFSPROC4_COMPOUND: u32 = 1;
92/// NFS procedure number for NULL (liveness probe).
93pub const NFSPROC4_NULL: u32 = 0;
94/// ONC RPC AUTH_SYS authentication flavor (Unix uid/gid credentials).
95pub const AUTH_SYS: u32 = 1;
96/// ONC RPC AUTH_NONE authentication flavor (no credentials).
97pub const AUTH_NONE: u32 = 0;
98
99/// OPEN share access: read only.
100pub const OPEN4_SHARE_ACCESS_READ: u32 = 0x00000001;
101/// OPEN share access: write only.
102pub const OPEN4_SHARE_ACCESS_WRITE: u32 = 0x00000002;
103/// OPEN share access: read and write.
104pub const OPEN4_SHARE_ACCESS_BOTH: u32 = 0x00000003;
105/// OPEN share deny: no deny (allow concurrent access).
106pub const OPEN4_SHARE_DENY_NONE: u32 = 0x00000000;
107/// OPEN claim type: open by filename in the current directory.
108pub const CLAIM_NULL: u32 = 0;
109/// OPEN type: create the file if it doesn't exist.
110pub const OPEN4_CREATE: u32 = 1;
111/// OPEN type: fail if the file doesn't exist.
112pub const OPEN4_NOCREATE: u32 = 0;
113/// Create mode: unchecked (overwrite existing attributes).
114pub const CREATEMODE4_UNCHECKED: u32 = 0;
115
116/// Write stability: server may cache, no durability guarantee.
117pub const UNSTABLE4: u32 = 0;
118/// Write stability: data flushed to stable storage, metadata may be cached.
119pub const DATA_SYNC4: u32 = 1;
120/// Write stability: data and metadata flushed to stable storage.
121pub const FILE_SYNC4: u32 = 2;
122
123/// FATTR4 bitmap position for file size (bit 4, word 0).
124pub const FATTR4_SIZE: u32 = 4;
125
126/// FATTR4 bitmap position for file mode/permissions (bit 33, word 1).
127pub const FATTR4_MODE: u32 = 33;
128/// FATTR4 bitmap position for file owner (bit 36, word 1).
129pub const FATTR4_OWNER: u32 = 36;
130/// FATTR4 bitmap position for file owner group (bit 37, word 1).
131pub const FATTR4_OWNER_GROUP: u32 = 37;
132/// FATTR4 bitmap position for modification time (bit 53, word 1).
133pub const FATTR4_TIME_MODIFY: u32 = 53;
134/// FATTR4 bitmap position for settable modification time (bit 52, word 1).
135pub const FATTR4_TIME_MODIFY_SET: u32 = 52;
136
137/// RPC reply status: message accepted by the server.
138pub const MSG_ACCEPTED: u32 = 0;
139/// RPC accept status: procedure executed successfully.
140pub const SUCCESS: u32 = 0;
141
142// -----------------------------------------------------------------------
143// Transport abstraction types (Wave 1)
144// -----------------------------------------------------------------------
145
146/// An RPC message separated from transport framing.
147///
148/// Contains the XDR-encoded RPC body (call or reply) without the 4-byte
149/// TCP record mark.  Wave 2 will use this struct to dispatch between
150/// TCP record-mark framing and RDMA `rdma_msg` framing (RFC 8166).
151#[derive(Debug, Clone)]
152pub struct RpcMessage {
153    /// Transaction identifier  --  echoed in the server reply.
154    pub xid: u32,
155    /// XDR-encoded RPC body (no record mark, no RDMA header).
156    pub body: Vec<u8>,
157}
158
159/// Prepend a TCP record mark to an XDR body.
160///
161/// Sets the last-fragment bit (MSB) and encodes the payload length in
162/// the lower 31 bits.  Standalone callers (e.g. [`super::client::probe_server_alive`])
163/// use this when they cannot go through [`super::client::NfsCompoundClient::rpc_call`].
164pub fn frame_rpc_message(body: &[u8]) -> Vec<u8> {
165    let rm = 0x80000000u32 | (body.len() as u32);
166    let mut msg = Vec::with_capacity(4 + body.len());
167    msg.extend_from_slice(&rm.to_be_bytes());
168    msg.extend_from_slice(body);
169    msg
170}
171
172// -----------------------------------------------------------------------
173// NFS4 compound operation types
174// -----------------------------------------------------------------------
175
176/// Represents an NFSv4 state identifier (returned by OPEN, used by WRITE/SETATTR/CLOSE).
177#[derive(Debug, Clone, Copy)]
178#[derive(Default)]
179pub struct StateId {
180    pub seqid: u32,
181    pub other: [u8; 12],
182}
183
184
185impl StateId {
186    /// The "current stateid" special value  --  tells the server to use the stateid
187    /// from the most recent stateful operation (OPEN) in this compound.
188    /// RFC 5661 S16.2.3.1.2: seqid=1, other=all-zeros.
189    /// (NOT seqid=0xFFFFFFFF/other=all-0xFF, which is the anonymous/READ bypass stateid.)
190    pub fn current() -> Self {
191        Self { seqid: 1, other: [0u8; 12] }
192    }
193}
194
195/// Write stability level.
196#[derive(Debug, Clone, Copy)]
197pub enum WriteStable {
198    Unstable,
199    DataSync,
200    FileSync,
201}
202
203/// A single NFSv4.2 compound operation.
204#[derive(Debug, Clone)]
205pub enum Nfs4Op {
206    Sequence {
207        session_id: [u8; 16],
208        sequence_id: u32,
209        slot_id: u32,
210        highest_slot_id: u32,
211        cache_this: bool,
212    },
213    PutFh {
214        handle: Vec<u8>,
215    },
216    PutRootFh,
217    Lookup {
218        name: String,
219    },
220    Open {
221        seqid: u32,
222        share_access: u32,
223        share_deny: u32,
224        clientid: u64,
225        owner: Vec<u8>,
226        filename: String,
227        mode: u32,
228        create_owner: Option<String>,
229        create_group: Option<String>,
230        create_mtime: Option<(i64, i64)>,
231    },
232    Write {
233        stateid: StateId,
234        offset: u64,
235        stable: WriteStable,
236        data: Vec<u8>,
237    },
238    Read {
239        stateid: StateId,
240        offset: u64,
241        count: u32,
242    },
243    SetAttr {
244        stateid: StateId,
245        mode: Option<u32>,
246        owner: Option<String>,
247        owner_group: Option<String>,
248        mtime: Option<(i64, i64)>,
249    },
250    Close {
251        seqid: u32,
252        stateid: StateId,
253    },
254    GetAttr {
255        attr_request: [u32; 2],
256    },
257    GetFh,
258    /// Tell server we have no state to reclaim (ends grace period for this session).
259    ReclaimComplete,
260}
261
262/// Parsed result of a single operation in a compound reply.
263#[derive(Debug)]
264pub struct OpResult {
265    pub op: u32,
266    pub status: u32,
267    /// For OPEN: the returned stateid.
268    pub stateid: Option<StateId>,
269    /// For GETFH: the returned filehandle.
270    pub filehandle: Option<Vec<u8>>,
271    /// For GETATTR: file size (if SIZE bit was in bitmap).
272    pub size: Option<u64>,
273    /// For GETATTR: modification time (seconds, nanoseconds).
274    pub mtime: Option<(i64, i64)>,
275    /// For READ: the returned file data.
276    pub data: Option<Vec<u8>>,
277}
278
279/// Parsed NFSv4.2 compound reply.
280#[derive(Debug)]
281pub struct CompoundReply {
282    pub xid: u32,
283    pub status: u32,
284    pub tag: Vec<u8>,
285    pub op_results: Vec<OpResult>,
286}
287
288// -----------------------------------------------------------------------
289// Encoding
290// -----------------------------------------------------------------------
291
292/// Encode AUTH_SYS credentials into XDR.
293fn encode_auth_sys(enc: &mut XdrEncoder, uid: u32, gid: u32, machine: &str) {
294    enc.encode_u32(AUTH_SYS);  // flavor
295    // Auth body (length-prefixed)
296    let mut body = XdrEncoder::new(64);
297    body.encode_u32(0);         // stamp
298    body.encode_string(machine); // machinename
299    body.encode_u32(uid);        // uid
300    body.encode_u32(gid);        // gid
301    body.encode_u32(1);          // gids count
302    body.encode_u32(gid);        // gids[0]
303    let body_bytes = body.into_bytes();
304    enc.encode_opaque(&body_bytes);
305}
306
307/// Encode a single NFSv4 operation into XDR.
308fn encode_op(enc: &mut XdrEncoder, op: &Nfs4Op) {
309    match op {
310        Nfs4Op::Sequence { session_id, sequence_id, slot_id, highest_slot_id, cache_this } => {
311            enc.encode_u32(OP_SEQUENCE);
312            enc.encode_opaque_fixed(session_id);  // 16 bytes, no length prefix
313            enc.encode_u32(*sequence_id);
314            enc.encode_u32(*slot_id);
315            enc.encode_u32(*highest_slot_id);
316            enc.encode_bool(*cache_this);
317        }
318        Nfs4Op::PutFh { handle } => {
319            enc.encode_u32(OP_PUTFH);
320            enc.encode_opaque(handle);
321        }
322        Nfs4Op::PutRootFh => {
323            enc.encode_u32(OP_PUTROOTFH);
324        }
325        Nfs4Op::Lookup { name } => {
326            enc.encode_u32(OP_LOOKUP);
327            enc.encode_string(name);
328        }
329        Nfs4Op::Open { seqid, share_access, share_deny, clientid, owner, filename, mode,
330                       create_owner, create_group, create_mtime } => {
331            enc.encode_u32(OP_OPEN);
332            enc.encode_u32(*seqid);
333            enc.encode_u32(*share_access);
334            enc.encode_u32(*share_deny);
335            enc.encode_u64(*clientid);
336            enc.encode_opaque(owner);
337            enc.encode_u32(OPEN4_CREATE);
338            enc.encode_u32(CREATEMODE4_UNCHECKED);
339            // createattrs fattr4: bitmap word 1 bits in ascending order
340            let mut bitmap_w1: u32 = 1 << (FATTR4_MODE - 32);
341            if create_owner.is_some() { bitmap_w1 |= 1 << (FATTR4_OWNER - 32); }
342            if create_group.is_some() { bitmap_w1 |= 1 << (FATTR4_OWNER_GROUP - 32); }
343            if create_mtime.is_some() { bitmap_w1 |= 1 << (FATTR4_TIME_MODIFY_SET - 32); }
344            enc.encode_u32(2);
345            enc.encode_u32(0);
346            enc.encode_u32(bitmap_w1);
347            let mut attr = XdrEncoder::new(128);
348            attr.encode_u32(*mode & 0o7777);
349            if let Some(o) = create_owner {
350                attr.encode_string(o);
351            }
352            if let Some(g) = create_group {
353                attr.encode_string(g);
354            }
355            if let Some((secs, nsecs)) = create_mtime {
356                attr.encode_u32(1); // SET_TO_CLIENT_TIME4
357                attr.encode_i64(*secs);
358                attr.encode_u32((*nsecs as u32).min(999_999_999));
359            }
360            let attr_bytes = attr.into_bytes();
361            enc.encode_opaque(&attr_bytes);
362            enc.encode_u32(CLAIM_NULL);
363            enc.encode_string(filename);
364        }
365        Nfs4Op::Write { stateid, offset, stable, data } => {
366            enc.encode_u32(OP_WRITE);
367            // stateid4
368            enc.encode_u32(stateid.seqid);
369            enc.encode_opaque_fixed(&stateid.other);
370            enc.encode_u64(*offset);
371            enc.encode_u32(match stable {
372                WriteStable::Unstable => UNSTABLE4,
373                WriteStable::DataSync => DATA_SYNC4,
374                WriteStable::FileSync => FILE_SYNC4,
375            });
376            enc.encode_opaque(data);
377        }
378        Nfs4Op::Read { stateid, offset, count } => {
379            enc.encode_u32(OP_READ);
380            enc.encode_u32(stateid.seqid);
381            enc.encode_opaque_fixed(&stateid.other);
382            enc.encode_u64(*offset);
383            enc.encode_u32(*count);
384        }
385        Nfs4Op::SetAttr { stateid, mode, owner, owner_group, mtime } => {
386            enc.encode_u32(OP_SETATTR);
387            enc.encode_u32(stateid.seqid);
388            enc.encode_opaque_fixed(&stateid.other);
389            let mut bitmap_w1: u32 = 0;
390            if mode.is_some() { bitmap_w1 |= 1 << (FATTR4_MODE - 32); }
391            if owner.is_some() { bitmap_w1 |= 1 << (FATTR4_OWNER - 32); }
392            if owner_group.is_some() { bitmap_w1 |= 1 << (FATTR4_OWNER_GROUP - 32); }
393            if mtime.is_some() { bitmap_w1 |= 1 << (FATTR4_TIME_MODIFY_SET - 32); }
394            enc.encode_u32(2);   // 2 bitmap words
395            enc.encode_u32(0);   // word 0
396            enc.encode_u32(bitmap_w1);
397            let mut attr = XdrEncoder::new(128);
398            if let Some(m) = mode {
399                attr.encode_u32(*m);
400            }
401            if let Some(o) = owner {
402                attr.encode_string(o);
403            }
404            if let Some(g) = owner_group {
405                attr.encode_string(g);
406            }
407            if let Some((secs, nsecs)) = mtime {
408                let clamped_nsecs = (*nsecs as u32).min(999_999_999);
409                if *nsecs < 0 || *nsecs > 999_999_999 {
410                    debug!(
411                        "SETATTR: clamping out-of-range nseconds {} -> {}",
412                        nsecs, clamped_nsecs
413                    );
414                }
415                attr.encode_u32(1); // SET_TO_CLIENT_TIME4
416                attr.encode_i64(*secs);
417                attr.encode_u32(clamped_nsecs);
418            }
419            let attr_bytes = attr.into_bytes();
420            debug!(
421                "SETATTR: bitmap_w1={:#010x} attr_vals={} bytes owner={:?} group={:?} mtime={:?}",
422                bitmap_w1, attr_bytes.len(), owner, owner_group, mtime
423            );
424            enc.encode_opaque(&attr_bytes);
425        }
426        Nfs4Op::Close { seqid, stateid } => {
427            enc.encode_u32(OP_CLOSE);
428            enc.encode_u32(*seqid);
429            enc.encode_u32(stateid.seqid);
430            enc.encode_opaque_fixed(&stateid.other);
431        }
432        Nfs4Op::GetAttr { attr_request } => {
433            enc.encode_u32(OP_GETATTR);
434            enc.encode_u32(2);  // bitmap length
435            enc.encode_u32(attr_request[0]);
436            enc.encode_u32(attr_request[1]);
437        }
438        Nfs4Op::GetFh => {
439            enc.encode_u32(OP_GETFH);
440        }
441        Nfs4Op::ReclaimComplete => {
442            enc.encode_u32(OP_RECLAIM_COMPLETE);
443            enc.encode_bool(false); // rca_one_fs = false (complete for all filesystems)
444        }
445    }
446}
447
448/// Build an NFSv4.2 COMPOUND RPC call body (XDR, no record mark).
449///
450/// Returns the XDR-encoded RPC body ready for [`frame_rpc_message`] or
451/// [`super::client::NfsCompoundClient::rpc_call`].
452pub fn build_compound(
453    xid: u32,
454    tag: &str,
455    uid: u32,
456    gid: u32,
457    machine: &str,
458    ops: &[Nfs4Op],
459) -> Vec<u8> {
460    let mut body = XdrEncoder::new(constants::NFS_XDR_ENCODER_BODY_CAPACITY);
461
462    // ONC RPC header
463    body.encode_u32(xid);            // XID
464    body.encode_u32(0);              // CALL (not REPLY)
465    body.encode_u32(RPC_VERSION);    // RPC version 2
466    body.encode_u32(NFS_PROGRAM);    // program: NFS
467    body.encode_u32(NFS_V4);         // version: 4
468    body.encode_u32(NFSPROC4_COMPOUND); // procedure: COMPOUND
469
470    // AUTH_SYS credentials
471    encode_auth_sys(&mut body, uid, gid, machine);
472
473    // Verifier (AUTH_NONE)
474    body.encode_u32(AUTH_NONE);
475    body.encode_u32(0); // verifier body length
476
477    // COMPOUND args
478    body.encode_string(tag);         // tag
479    body.encode_u32(2);              // minor version (4.2)
480    body.encode_u32(ops.len() as u32); // argarray count
481
482    for op in ops {
483        encode_op(&mut body, op);
484    }
485
486    body.into_bytes()
487}
488
489/// Parse a compound reply, extracting status codes and the OPEN stateid.
490///
491/// This is a minimal parser  --  it extracts enough to determine success/failure
492/// and to get the stateid from OPEN (needed for WRITE, SETATTR, CLOSE).
493pub fn parse_compound_reply(data: &[u8]) -> Result<CompoundReply, NfsError> {
494    // Skip record mark (4 bytes)
495    if data.len() < 4 {
496        return Err(NfsError::XdrDecode("reply too short".into()));
497    }
498    let mut dec = XdrDecoder::new(&data[4..]);
499
500    // RPC reply header
501    let xid = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
502    let msg_type = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
503    if msg_type != 1 { // REPLY
504        return Err(NfsError::RpcError(format!("expected REPLY (1), got {}", msg_type)));
505    }
506
507    let reply_stat = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
508    if reply_stat != MSG_ACCEPTED {
509        return Err(NfsError::RpcError(format!("RPC rejected: {}", reply_stat)));
510    }
511
512    // Verifier
513    let _verf_flavor = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
514    let _verf_body = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
515
516    // Accept status
517    let accept_stat = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
518    if accept_stat != SUCCESS {
519        return Err(NfsError::RpcError(format!("RPC accept error: {}", accept_stat)));
520    }
521
522    // COMPOUND reply
523    let compound_status = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
524    let tag = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?.to_vec();
525    let num_results = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
526
527    let mut op_results = Vec::with_capacity(num_results as usize);
528    for _ in 0..num_results {
529        let op = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
530        let status = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
531
532        let mut filehandle = None;
533        let stateid = None;
534        let mut size = None;
535        let mut mtime = None;
536        let mut data_field: Option<Vec<u8>> = None;
537
538        // Parse enough of each op result to skip to the next
539        if status == NFS4_OK {
540            match op {
541                OP_SEQUENCE => {
542                    dec.skip_raw(16 + 4 + 4 + 4 + 4 + 4).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
543                }
544                OP_PUTFH | OP_PUTROOTFH | OP_LOOKUP | OP_RECLAIM_COMPLETE => {
545                    // No result data
546                }
547                OP_GETFH => {
548                    let fh = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
549                    filehandle = Some(fh.to_vec());
550                }
551                OP_OPEN => {
552                    // OPEN4resok: stateid + change_info4 + rflags + bitmap4 + delegation
553                    let sid_seqid = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
554                    let sid_other = dec.decode_opaque_fixed(12).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
555                    let mut other = [0u8; 12];
556                    other.copy_from_slice(sid_other);
557
558                    // change_info4: atomic(bool=4) + before(u64=8) + after(u64=8) = 20 bytes
559                    dec.skip_raw(20).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
560                    // rflags: 4 bytes
561                    dec.skip_raw(4).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
562                    // bitmap4 attrset: count(u32) + count x u32
563                    let bm_len = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
564                    dec.skip_raw(bm_len as usize * 4).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
565                    // open_delegation4: discriminant + type-dependent body
566                    let deleg_type = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
567                    match deleg_type {
568                        0 => { /* OPEN_DELEGATE_NONE: void */ }
569                        3 => {
570                            // OPEN_DELEGATE_NONE_EXT: ond_why(u32) + optional bool
571                            let why = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
572                            if why == 1 || why == 2 {
573                                dec.skip_raw(4).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
574                            }
575                        }
576                        _ => {
577                            // READ/WRITE delegation  --  variable-length nfsace4, can't skip safely
578                            op_results.push(OpResult { op, status, stateid: Some(StateId { seqid: sid_seqid, other }), filehandle: None, size: None, mtime: None, data: None });
579                            break;
580                        }
581                    }
582
583                    op_results.push(OpResult { op, status, stateid: Some(StateId { seqid: sid_seqid, other }), filehandle: None, size: None, mtime: None, data: None });
584                    continue;
585                }
586                OP_WRITE => {
587                    dec.skip_raw(4 + 4 + 8).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
588                }
589                OP_SETATTR => {
590                    let bm_len = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
591                    dec.skip_raw(bm_len as usize * 4).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
592                }
593                OP_CLOSE => {
594                    dec.skip_raw(4 + 12).map_err(|e| NfsError::XdrDecode(e.to_string()))?;
595                }
596                OP_READ => {
597                    let _eof = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
598                    let raw = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
599                    data_field = Some(raw.to_vec());
600                }
601                OP_GETATTR => {
602                    // Parse bitmap + attribute values for SIZE and TIME_MODIFY
603                    let bm_len = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
604                    let mut bitmap = vec![0u32; bm_len as usize];
605                    for b in bitmap.iter_mut() {
606                        *b = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
607                    }
608                    let attr_data = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
609                    // Parse requested attributes from the opaque value in bitmap order
610                    let mut attr_dec = XdrDecoder::new(attr_data);
611                    if bm_len > 0 && (bitmap[0] & (1 << FATTR4_SIZE)) != 0 {
612                        size = attr_dec.decode_u64().ok();
613                    }
614                    if bm_len > 1 && (bitmap[1] & (1 << (FATTR4_TIME_MODIFY - 32))) != 0 {
615                        // nfstime4: seconds (i64) + nseconds (u32)
616                        if let (Ok(secs), Ok(nsecs)) = (attr_dec.decode_u64(), attr_dec.decode_u32()) {
617                            mtime = Some((secs as i64, nsecs as i64));
618                        }
619                    }
620                }
621                _ => {
622                    break; // Unknown op  --  can't skip safely
623                }
624            }
625        }
626
627        op_results.push(OpResult { op, status, stateid, filehandle, size, mtime, data: data_field });
628    }
629
630    Ok(CompoundReply {
631        xid,
632        status: compound_status,
633        tag,
634        op_results,
635    })
636}
637
638/// Get the human-readable name for an NFS4 error code.
639pub fn nfs4_error_name(code: u32) -> &'static str {
640    match code {
641        NFS4_OK => "NFS4_OK",
642        1 => "NFS4ERR_PERM",
643        NFS4ERR_NOENT => "NFS4ERR_NOENT",
644        5 => "NFS4ERR_IO",
645        6 => "NFS4ERR_NXIO",
646        NFS4ERR_ACCESS => "NFS4ERR_ACCESS",
647        NFS4ERR_EXIST => "NFS4ERR_EXIST",
648        18 => "NFS4ERR_XDEV",
649        NFS4ERR_NOTDIR => "NFS4ERR_NOTDIR",
650        NFS4ERR_ISDIR => "NFS4ERR_ISDIR",
651        NFS4ERR_INVAL => "NFS4ERR_INVAL",
652        NFS4ERR_FBIG => "NFS4ERR_FBIG",
653        NFS4ERR_NOSPC => "NFS4ERR_NOSPC",
654        NFS4ERR_ROFS => "NFS4ERR_ROFS",
655        NFS4ERR_NAMETOOLONG => "NFS4ERR_NAMETOOLONG",
656        NFS4ERR_NOTEMPTY => "NFS4ERR_NOTEMPTY",
657        NFS4ERR_STALE => "NFS4ERR_STALE",
658        10003 => "NFS4ERR_BADHANDLE",
659        10006 => "NFS4ERR_SERVERFAULT",
660        10008 => "NFS4ERR_DELAY",
661        10010 => "NFS4ERR_SAME",
662        10011 => "NFS4ERR_DENIED",
663        10013 => "NFS4ERR_GRACE",
664        10015 => "NFS4ERR_SHARE_DENIED",
665        10016 => "NFS4ERR_WRONGSEC",
666        10019 => "NFS4ERR_REQ_TOO_BIG",
667        NFS4ERR_BAD_STATEID => "NFS4ERR_BAD_STATEID",
668        NFS4ERR_BADSEQ => "NFS4ERR_BADSEQ",
669        10028 => "NFS4ERR_REP_TOO_BIG",
670        NFS4ERR_BADSESSION => "NFS4ERR_BADSESSION",
671        10053 => "NFS4ERR_BADSLOT",
672        10054 => "NFS4ERR_COMPLETE_ALREADY",
673        NFS4ERR_SEQ_MISORDERED => "NFS4ERR_SEQ_MISORDERED",
674        10044 => "NFS4ERR_OP_ILLEGAL",
675        10068 => "NFS4ERR_RETRY_UNCACHED_REP",
676        _ => "NFS4ERR_UNKNOWN",
677    }
678}
679
680/// ONC RPC RPCSEC_GSS authentication flavor (RFC 2203).
681pub const AUTH_RPCSEC_GSS: u32 = 6;
682/// ONC RPC AUTH_TLS authentication flavor (RFC 9289 S4.1).
683///
684/// Used in the credential field of a NULL RPC call to probe whether the
685/// server supports RPC-over-TLS.  The server replies with a verifier body
686/// of `b"STARTTLS"` when TLS is available.
687pub const AUTH_TLS: u32 = 7;
688/// RPCSEC_GSS protocol version.
689pub const RPCSEC_GSS_VERSION: u32 = 1;
690/// RPCSEC_GSS procedure: data exchange (after context established).
691pub const RPCSEC_GSS_DATA: u32 = 0;
692/// RPCSEC_GSS procedure: initial context establishment (RFC 2203 S5.2.2).
693#[cfg(feature = "krb5")]
694pub const RPCSEC_GSS_INIT: u32 = 1;
695/// RPCSEC_GSS procedure: continuation of multi-round context establishment.
696#[cfg(feature = "krb5")]
697pub const RPCSEC_GSS_CONTINUE_INIT: u32 = 2;
698
699/// Build an RPCSEC_GSS-authenticated NFSv4.2 COMPOUND call.
700///
701/// Replaces AUTH_SYS credentials with RPCSEC_GSS credentials and computes
702/// the verifier MIC over the RPC header per RFC 2203 S5.3.3.1.
703/// For krb5p (privacy), the compound args body is wrapped via `gss.wrap_body()`.
704#[cfg(feature = "krb5")]
705pub fn build_compound_gss(
706    xid: u32,
707    tag: &str,
708    ops: &[Nfs4Op],
709    gss: &mut super::gss::GssContext,
710) -> Result<Vec<u8>, super::NfsError> {
711    use super::gss::RpcGssService;
712
713    let service = gss.rpc_gss_service();
714
715    // RFC 2203 S5.3.3.3: seq_num starts at 1, increments by 1 per DATA call
716    gss.sequence_num += 1;
717
718    // Phase 1: Encode compound args (tag + minor_version + ops).
719    let mut args_enc = XdrEncoder::new(constants::NFS_XDR_ENCODER_BODY_CAPACITY);
720    args_enc.encode_string(tag);
721    args_enc.encode_u32(2); // minor version 4.2
722    args_enc.encode_u32(ops.len() as u32);
723    for op in ops {
724        encode_op(&mut args_enc, op);
725    }
726    let compound_args = args_enc.into_bytes();
727
728    debug!(
729        "build_compound_gss: tag={} ops={} compound_args={} bytes, last 32: {:02x?}",
730        tag, ops.len(), compound_args.len(),
731        &compound_args[compound_args.len().saturating_sub(32)..]
732    );
733
734    // Phase 2: Encode RPC header + RPCSEC_GSS credential.
735    let mut header = XdrEncoder::new(constants::NFS_XDR_ENCODER_TINY_CAPACITY);
736    header.encode_u32(xid);
737    header.encode_u32(0); // CALL
738    header.encode_u32(RPC_VERSION);
739    header.encode_u32(NFS_PROGRAM);
740    header.encode_u32(NFS_V4);
741    header.encode_u32(NFSPROC4_COMPOUND);
742
743    // RPCSEC_GSS credential (RFC 2203 S5.2.2)
744    let mut cred_body = XdrEncoder::new(64);
745    cred_body.encode_u32(RPCSEC_GSS_VERSION);
746    cred_body.encode_u32(RPCSEC_GSS_DATA);
747    cred_body.encode_u32(gss.sequence_num);
748    cred_body.encode_u32(service as u32);
749    cred_body.encode_opaque(&gss.server_handle);
750    let cred_bytes = cred_body.into_bytes();
751
752    header.encode_u32(AUTH_RPCSEC_GSS);
753    header.encode_opaque(&cred_bytes);
754
755    let header_bytes = header.into_bytes();
756
757    // Phase 3: Verifier = MIC over RPC header (xid through credential).
758    let mic = gss.get_mic(&header_bytes)?;
759
760    // Phase 4: Assemble full message.
761    let mut body = XdrEncoder::new(header_bytes.len() + mic.len() + compound_args.len() + 64);
762    body.encode_raw(&header_bytes);
763
764    // Verifier: flavor=RPCSEC_GSS, body=MIC
765    body.encode_u32(AUTH_RPCSEC_GSS);
766    body.encode_opaque(&mic);
767
768    // Phase 5: Procedure args  --  service-dependent encoding.
769    match service {
770        RpcGssService::None => {
771            // krb5: plaintext args
772            body.encode_raw(&compound_args);
773        }
774        RpcGssService::Integrity => {
775            // krb5i: { seq_num || args } + MIC over that (RFC 2203 S5.3.2.2)
776            let mut integ_data = XdrEncoder::new(compound_args.len() + 8);
777            integ_data.encode_u32(gss.sequence_num);
778            integ_data.encode_raw(&compound_args);
779            let integ_bytes = integ_data.into_bytes();
780
781            let integ_mic = gss.get_mic(&integ_bytes)?;
782            body.encode_opaque(&integ_bytes);
783            body.encode_opaque(&integ_mic);
784        }
785        RpcGssService::Privacy => {
786            // krb5p: wrap(seq_num || args) as single unit (RFC 2203 S5.3.2.3)
787            let mut priv_data = XdrEncoder::new(compound_args.len() + 8);
788            priv_data.encode_u32(gss.sequence_num);
789            priv_data.encode_raw(&compound_args);
790            let priv_bytes = priv_data.into_bytes();
791
792            let wrapped = gss.wrap_body(&priv_bytes)?;
793            debug!(
794                "krb5p wrap: priv_bytes={} wrapped={} overhead={} token_hdr={:02x?}",
795                priv_bytes.len(), wrapped.len(),
796                wrapped.len().saturating_sub(priv_bytes.len()),
797                &wrapped[..wrapped.len().min(16)]
798            );
799            body.encode_opaque(&wrapped);
800        }
801    }
802
803    Ok(body.into_bytes())
804}
805
806/// Build an RPCSEC_GSS INIT RPC to exchange the client's GSS token for a
807/// server-assigned context handle (RFC 2203 S5.2.2).
808///
809/// Uses NFS NULL procedure with RPCSEC_GSS credential (proc=INIT, seq=0).
810/// `handle` is empty for the first INIT; set to the server's handle for
811/// CONTINUE_INIT rounds.
812#[cfg(feature = "krb5")]
813pub fn build_rpcsec_gss_init(
814    xid: u32,
815    handle: &[u8],
816    gss_token: &[u8],
817) -> Vec<u8> {
818    let mut body = XdrEncoder::new(256 + gss_token.len());
819
820    // RPC header
821    body.encode_u32(xid);
822    body.encode_u32(0); // CALL
823    body.encode_u32(RPC_VERSION);
824    body.encode_u32(NFS_PROGRAM);
825    body.encode_u32(NFS_V4);
826    body.encode_u32(NFSPROC4_NULL); // NULL procedure
827
828    // RPCSEC_GSS credential
829    let proc = if handle.is_empty() { RPCSEC_GSS_INIT } else { RPCSEC_GSS_CONTINUE_INIT };
830    let mut cred_body = XdrEncoder::new(64);
831    cred_body.encode_u32(RPCSEC_GSS_VERSION); // version = 1
832    cred_body.encode_u32(proc);                // RPCSEC_GSS_INIT or CONTINUE_INIT
833    cred_body.encode_u32(0);                   // seq_num = 0 for INIT
834    cred_body.encode_u32(1);                   // service = rpc_gss_svc_none
835    cred_body.encode_opaque(handle);           // context handle (empty for first INIT)
836    let cred_bytes = cred_body.into_bytes();
837
838    body.encode_u32(AUTH_RPCSEC_GSS); // credential flavor
839    body.encode_opaque(&cred_bytes);  // credential body
840
841    // Verifier: AUTH_NONE
842    body.encode_u32(AUTH_NONE);
843    body.encode_u32(0);
844
845    // Call body: gss_init_arg = opaque gss_token<>
846    body.encode_opaque(gss_token);
847
848    body.into_bytes()
849}
850
851/// Parse an RPCSEC_GSS INIT reply (RFC 2203 S5.2.2).
852///
853/// Returns `(server_handle, gss_major, gss_minor, seq_window, continuation_token)`.
854#[cfg(feature = "krb5")]
855pub fn parse_rpcsec_gss_init_reply(
856    data: &[u8],
857) -> Result<(Vec<u8>, u32, u32, u32, Vec<u8>), NfsError> {
858    if data.len() < 4 {
859        return Err(NfsError::XdrDecode("RPCSEC_GSS INIT reply too short".into()));
860    }
861    let mut dec = XdrDecoder::new(&data[4..]); // skip record mark
862
863    // RPC reply header
864    let _xid = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
865    let msg_type = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
866    if msg_type != 1 {
867        return Err(NfsError::RpcError(format!("expected REPLY (1), got {}", msg_type)));
868    }
869
870    let reply_stat = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
871    if reply_stat != MSG_ACCEPTED {
872        return Err(NfsError::RpcError(format!("RPCSEC_GSS INIT rejected: {}", reply_stat)));
873    }
874
875    // Verifier
876    let _verf_flavor = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
877    let _verf_body = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
878
879    // Accept status
880    let accept_stat = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
881    if accept_stat != SUCCESS {
882        return Err(NfsError::RpcError(format!("RPCSEC_GSS INIT accept error: {}", accept_stat)));
883    }
884
885    // gss_init_res body
886    let server_handle = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?.to_vec();
887    let gss_major = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
888    let gss_minor = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
889    let seq_window = dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
890    let cont_token = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?.to_vec();
891
892    Ok((server_handle, gss_major, gss_minor, seq_window, cont_token))
893}
894
895/// Unwrap a GSS-protected NFS compound reply for `parse_compound_reply()`.
896///
897/// For krb5p: decrypts the wrapped body. For krb5i: verifies the MIC.
898/// For krb5 (auth-only): passes through unchanged.
899/// Returns data suitable for `parse_compound_reply()`.
900#[cfg(feature = "krb5")]
901pub fn unwrap_gss_reply(
902    data: &[u8],
903    gss: &mut super::gss::GssContext,
904) -> Result<Vec<u8>, NfsError> {
905    use super::gss::RpcGssService;
906
907    let service = gss.rpc_gss_service();
908    if matches!(service, RpcGssService::None) {
909        return Ok(data.to_vec());
910    }
911
912    if data.len() < 4 {
913        return Err(NfsError::XdrDecode("GSS reply too short".into()));
914    }
915    let mut dec = XdrDecoder::new(&data[4..]); // skip record mark
916
917    // Parse RPC reply header (always plaintext)
918    dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // xid
919    dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // msg_type
920    dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // reply_stat
921    dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // verf_flavor
922    dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // verf_body
923    dec.decode_u32().map_err(|e| NfsError::XdrDecode(e.to_string()))?; // accept_stat
924
925    let header_end = 4 + dec.position(); // offset after RPC header in original data
926
927    let compound_body = match service {
928        RpcGssService::None => unreachable!(),
929        RpcGssService::Privacy => {
930            let wrapped = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
931            let plaintext = gss.unwrap_body(wrapped)?;
932            if plaintext.len() < 4 {
933                return Err(NfsError::XdrDecode("unwrapped krb5p reply too short for seq_num".into()));
934            }
935            plaintext[4..].to_vec() // skip seq_num prefix
936        }
937        RpcGssService::Integrity => {
938            let integ_data = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
939            let checksum = dec.decode_opaque().map_err(|e| NfsError::XdrDecode(e.to_string()))?;
940            gss.verify_mic(integ_data, checksum)?;
941            if integ_data.len() < 4 {
942                return Err(NfsError::XdrDecode("krb5i integrity data too short for seq_num".into()));
943            }
944            integ_data[4..].to_vec() // skip seq_num prefix
945        }
946    };
947
948    debug!(
949        "unwrap_gss_reply: header_end={} compound_body={} bytes, hex: {:02x?}",
950        header_end, compound_body.len(), &compound_body[..compound_body.len().min(200)]
951    );
952
953    // Reconstruct: original RPC header + plaintext compound body
954    let total_len = header_end + compound_body.len();
955    let mut result = Vec::with_capacity(total_len);
956    result.extend_from_slice(&data[..header_end]);
957    result.extend_from_slice(&compound_body);
958    // Fix record mark to reflect new payload size
959    let rm = 0x80000000u32 | ((total_len - 4) as u32);
960    result[..4].copy_from_slice(&rm.to_be_bytes());
961
962    Ok(result)
963}
964
965/// Build a minimal NFS NULL RPC call (zero-overhead server liveness check).
966///
967/// The NULL procedure requires no session, no filehandle, no authentication.
968/// Response time is <1ms when the server is reachable. Standard NFS liveness probe.
969pub fn build_null_call(xid: u32) -> Vec<u8> {
970    let mut body = XdrEncoder::new(64);
971    body.encode_u32(xid);
972    body.encode_u32(0);              // CALL
973    body.encode_u32(RPC_VERSION);
974    body.encode_u32(NFS_PROGRAM);
975    body.encode_u32(NFS_V4);
976    body.encode_u32(NFSPROC4_NULL);  // procedure 0 = NULL
977    // AUTH_NONE credentials + verifier
978    body.encode_u32(AUTH_NONE);
979    body.encode_u32(0);
980    body.encode_u32(AUTH_NONE);
981    body.encode_u32(0);
982    body.into_bytes()
983}
984
985/// Build an AUTH_TLS probe (RFC 9289 S4.1) to detect RPC-over-TLS support.
986///
987/// Identical to [`build_null_call`] except the credential flavor is
988/// `AUTH_TLS` (7) instead of `AUTH_NONE` (0).  The verifier remains
989/// `AUTH_NONE`.  If the server supports TLS it replies with a verifier
990/// body of `b"STARTTLS"`.
991pub fn build_auth_tls_probe(xid: u32) -> Vec<u8> {
992    let mut body = XdrEncoder::new(64);
993    body.encode_u32(xid);
994    body.encode_u32(0); // CALL
995    body.encode_u32(RPC_VERSION);
996    body.encode_u32(NFS_PROGRAM);
997    body.encode_u32(NFS_V4);
998    body.encode_u32(NFSPROC4_NULL);
999    // AUTH_TLS credential (flavor=7, body_len=0)
1000    body.encode_u32(AUTH_TLS);
1001    body.encode_u32(0);
1002    // AUTH_NONE verifier
1003    body.encode_u32(AUTH_NONE);
1004    body.encode_u32(0);
1005    body.into_bytes()
1006}
1007
1008/// Parse an ONC RPC reply to an AUTH_TLS probe (RFC 9289 S4.1).
1009///
1010/// Returns `true` when the server indicates TLS support: the reply must be
1011/// `MSG_ACCEPTED` with a verifier whose body is exactly `b"STARTTLS"`.
1012pub fn parse_auth_tls_reply(reply: &[u8]) -> bool {
1013    // Minimum: 4 RM + 4 XID + 4 REPLY + 4 accept_status
1014    //        + 4 verf_flavor + 4 verf_len + 8 verf_body = 32
1015    if reply.len() < 32 {
1016        return false;
1017    }
1018    let mut dec = XdrDecoder::new(&reply[4..]); // skip record mark
1019    let Ok(_xid) = dec.decode_u32() else { return false };
1020    let Ok(msg_type) = dec.decode_u32() else { return false };
1021    if msg_type != 1 {
1022        return false;
1023    }
1024    let Ok(accept_status) = dec.decode_u32() else { return false };
1025    if accept_status != MSG_ACCEPTED {
1026        return false;
1027    }
1028    let Ok(verf_flavor) = dec.decode_u32() else { return false };
1029    let Ok(verf_len) = dec.decode_u32() else { return false };
1030    if verf_flavor != AUTH_NONE || verf_len != 8 {
1031        return false;
1032    }
1033    if dec.remaining() < 8 {
1034        return false;
1035    }
1036    let Ok(verf_body) = dec.decode_opaque_fixed(8) else { return false };
1037    verf_body == b"STARTTLS"
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1043    use super::*;
1044
1045    #[test]
1046    fn auth_tls_probe_xdr_encoding() {
1047        let probe = build_auth_tls_probe(0x42);
1048        // Body only (no record mark): 10 x u32 = 40 bytes
1049        assert_eq!(probe.len(), 40);
1050        assert_eq!(u32::from_be_bytes(probe[0..4].try_into().unwrap()), 0x42); // XID
1051        assert_eq!(u32::from_be_bytes(probe[4..8].try_into().unwrap()), 0); // CALL
1052        assert_eq!(u32::from_be_bytes(probe[8..12].try_into().unwrap()), 2); // RPC ver
1053        assert_eq!(u32::from_be_bytes(probe[12..16].try_into().unwrap()), 100003); // NFS prog
1054        assert_eq!(u32::from_be_bytes(probe[16..20].try_into().unwrap()), 4); // NFSv4
1055        assert_eq!(u32::from_be_bytes(probe[20..24].try_into().unwrap()), 0); // NULL proc
1056        assert_eq!(u32::from_be_bytes(probe[24..28].try_into().unwrap()), 7); // AUTH_TLS
1057        assert_eq!(u32::from_be_bytes(probe[28..32].try_into().unwrap()), 0); // cred len
1058        assert_eq!(u32::from_be_bytes(probe[32..36].try_into().unwrap()), 0); // verf AUTH_NONE
1059        assert_eq!(u32::from_be_bytes(probe[36..40].try_into().unwrap()), 0); // verf len
1060    }
1061
1062    #[test]
1063    fn auth_tls_probe_differs_from_null_only_in_credential_flavor() {
1064        let null_call = build_null_call(0xAA);
1065        let tls_probe = build_auth_tls_probe(0xAA);
1066        assert_eq!(null_call.len(), tls_probe.len());
1067        // Everything before credential flavor (offset 24) is identical
1068        assert_eq!(&null_call[..24], &tls_probe[..24]);
1069        // Credential flavor: AUTH_NONE(0) vs AUTH_TLS(7)
1070        assert_eq!(u32::from_be_bytes(null_call[24..28].try_into().unwrap()), 0);
1071        assert_eq!(u32::from_be_bytes(tls_probe[24..28].try_into().unwrap()), 7);
1072        // Rest (cred body len + verifier) identical
1073        assert_eq!(&null_call[28..], &tls_probe[28..]);
1074    }
1075
1076    #[test]
1077    fn frame_rpc_message_prepends_record_mark() {
1078        let body = build_null_call(0x42);
1079        let framed = frame_rpc_message(&body);
1080        assert_eq!(framed.len(), 4 + body.len());
1081        let rm = u32::from_be_bytes(framed[0..4].try_into().unwrap());
1082        assert_eq!(rm, 0x80000000 | (body.len() as u32));
1083        assert_eq!(&framed[4..], &body);
1084    }
1085
1086    #[test]
1087    fn parse_auth_tls_reply_detects_starttls() {
1088        let mut reply = Vec::new();
1089        reply.extend_from_slice(&(0x80000000u32 | 32).to_be_bytes()); // RM
1090        reply.extend_from_slice(&0xAAu32.to_be_bytes()); // XID
1091        reply.extend_from_slice(&1u32.to_be_bytes()); // REPLY
1092        reply.extend_from_slice(&0u32.to_be_bytes()); // MSG_ACCEPTED
1093        reply.extend_from_slice(&0u32.to_be_bytes()); // verf flavor AUTH_NONE
1094        reply.extend_from_slice(&8u32.to_be_bytes()); // verf body len
1095        reply.extend_from_slice(b"STARTTLS"); // verf body
1096        reply.extend_from_slice(&0u32.to_be_bytes()); // accept_stat SUCCESS
1097        assert!(parse_auth_tls_reply(&reply));
1098    }
1099
1100    #[test]
1101    fn parse_auth_tls_reply_rejects_no_tls_support() {
1102        // Normal AUTH_NONE NULL reply  --  empty verifier
1103        let mut reply = Vec::new();
1104        reply.extend_from_slice(&(0x80000000u32 | 24).to_be_bytes());
1105        reply.extend_from_slice(&0xBBu32.to_be_bytes());
1106        reply.extend_from_slice(&1u32.to_be_bytes());
1107        reply.extend_from_slice(&0u32.to_be_bytes());
1108        reply.extend_from_slice(&0u32.to_be_bytes()); // verf flavor
1109        reply.extend_from_slice(&0u32.to_be_bytes()); // verf body len = 0
1110        reply.extend_from_slice(&0u32.to_be_bytes()); // accept_stat
1111        assert!(!parse_auth_tls_reply(&reply));
1112    }
1113
1114    #[test]
1115    fn parse_auth_tls_reply_rejects_truncated_input() {
1116        assert!(!parse_auth_tls_reply(&[]));
1117        assert!(!parse_auth_tls_reply(&[0u8; 16]));
1118        assert!(!parse_auth_tls_reply(&[0u8; 31]));
1119    }
1120
1121    #[test]
1122    fn test_read_op_encode() {
1123        let stateid = StateId { seqid: 0, other: [0u8; 12] };
1124        let op = Nfs4Op::Read { stateid, offset: 0, count: 8192 };
1125        let mut enc = XdrEncoder::new(64);
1126        encode_op(&mut enc, &op);
1127        let bytes = enc.into_bytes();
1128        // OP_READ (4) + seqid (4) + other (12) + offset (8) + count (4) = 32 bytes
1129        assert_eq!(bytes.len(), 32, "READ op XDR should be 32 bytes");
1130        // First 4 bytes = OP_READ = 25 in big-endian
1131        assert_eq!(&bytes[0..4], &25u32.to_be_bytes(), "First 4 bytes should be OP_READ (25)");
1132        // Offset field (bytes 20..28) = 0
1133        assert_eq!(&bytes[20..28], &0u64.to_be_bytes(), "Offset should be 0");
1134        // Count field (last 4 bytes) = 8192
1135        assert_eq!(&bytes[28..32], &8192u32.to_be_bytes(), "Last 4 bytes should be count (8192)");
1136    }
1137
1138    #[test]
1139    fn test_read_op_result_data_field() {
1140        let result = OpResult {
1141            op: OP_READ,
1142            status: 0,
1143            stateid: None,
1144            filehandle: None,
1145            size: None,
1146            mtime: None,
1147            data: Some(vec![1u8, 2, 3, 4]),
1148        };
1149        assert_eq!(result.data, Some(vec![1u8, 2, 3, 4]));
1150        assert_eq!(result.op, OP_READ);
1151        assert_eq!(result.status, 0);
1152    }
1153}