Skip to main content

nfs_rs/nfs3/
mod.rs

1// Copyright 2025 NetApp Inc. All Rights Reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17mod access;
18mod commit;
19mod create;
20mod export;
21mod fsinfo;
22mod fsstat;
23mod getattr;
24mod link;
25mod lookup;
26mod mkdir;
27mod mount;
28mod null;
29mod pathconf;
30mod read;
31mod readdir;
32mod readdirplus;
33mod readlink;
34mod remove;
35mod rename;
36mod rmdir;
37mod setattr;
38mod symlink;
39mod umount;
40mod write;
41
42pub(crate) use mount::{mount, query_exports};
43
44use crate::error::{NfsError, RequestContext, Result, classify_sent_nfs3_error};
45use crate::{Auth, ObjRes, Time, rpc};
46use bytes::Bytes;
47
48/// Convert NFS byte-string to Rust String. Tries UTF-8 first;
49/// falls back to lossy conversion with a warning for non-UTF-8 filenames.
50pub(crate) fn bytes_to_string(raw: Bytes) -> String {
51    match std::str::from_utf8(&raw) {
52        Ok(s) => s.to_owned(),
53        Err(_) => {
54            tracing::warn!(raw_hex = %hex_preview(&raw), "NFS name contains invalid UTF-8, using lossy conversion");
55            String::from_utf8_lossy(&raw).into_owned()
56        }
57    }
58}
59
60fn hex_preview(bytes: &[u8]) -> String {
61    let limit = bytes.len().min(32);
62    let hex: String = bytes[..limit]
63        .iter()
64        .map(|b| format!("{:02x}", b))
65        .collect::<Vec<_>>()
66        .join(" ");
67    if bytes.len() > 32 {
68        format!("{}...", hex)
69    } else {
70        hex
71    }
72}
73
74pub(crate) mod fastxdr;
75
76// Re-export fastxdr response types used by procedure files.
77// Note: filename3 and nfspath3 are NOT re-exported here because we have local
78// request-encoding structs with those names. Callers use the local encoding types.
79pub(crate) use fastxdr::{
80    ACCESS3resok, COMMIT3resok, CREATE3resok, FSINFO3resok, FSSTAT3resok, GETATTR3resok,
81    LINK3resok, LOOKUP3resok, MKDIR3resok, PATHCONF3resok, READ3resok, READDIR3resok,
82    READDIRPLUS3resok, READLINK3resok, REMOVE3resok, RENAME3resok, RMDIR3resok, SETATTR3resok,
83    SYMLINK3resok, WRITE3resok, entry3, entryplus3, fattr3, mount_mountstat3, mountres3_ok,
84    nfsstat3, nfstime3, post_op_attr, post_op_fh3, stable_how,
85};
86
87/// Shared paging logic for `readdir` and `readdirplus` streams.
88///
89/// Generates a `try_unfold + try_flatten` stream that fetches directory pages
90/// via `$fetch_page`, then yields entries directly from the XDR linked list
91/// without an intermediate `Vec` — each non-special entry node is converted by
92/// `$convert`. NFSv3 servers may include `.` and `..`; those names are omitted
93/// from the public stream while their cookies still count as page progress.
94/// RFC 1813 sections 3.3.16 and 3.3.17 define the raw `READDIR` and
95/// `READDIRPLUS` entry streams normalized here.
96///
97/// The linked list is walked twice per page: once (read-only) to find the last
98/// cookie and entry count, then once (destructive) via `from_fn` to yield entries.
99macro_rules! paged_dir_stream {
100    ($self:expr_2021, $dir_fh:expr_2021, $fetch_page:ident, $convert:expr_2021, $label:literal) => {{
101        let this = $self;
102        futures::stream::try_unfold(
103            Some(($dir_fh, crate::mount::DirectoryCursor::default())),
104            move |state| async move {
105                let Some((fh, mut cursor)) = state else {
106                    return Ok::<_, crate::error::NfsError>(None);
107                };
108                let cookie = cursor.cookie;
109                let res = this
110                    .$fetch_page(fh.clone(), cookie, cursor.verifier)
111                    .await?;
112                let new_verifier: [u8; 8] =
113                    res.cookieverf.0.as_ref().try_into().unwrap_or([0u8; 8]);
114                let eof = res.reply.eof;
115                // Walk linked list (read-only) for last cookie and count.
116                let (new_cookie, entry_count, entries_head) = match res.reply.entries {
117                    Some(entry) => {
118                        let mut count = 0usize;
119                        let mut e = &*entry;
120                        let last_cookie = loop {
121                            count += 1;
122                            match &e.nextentry {
123                                Some(next) => e = next,
124                                None => break e.cookie.0,
125                            }
126                        };
127                        (last_cookie, count, Some(entry))
128                    }
129                    None => (cookie, 0, None),
130                };
131                tracing::debug!(cookie = new_cookie, eof, entry_count, $label);
132                let next = if cursor.advance(new_cookie, new_verifier, entry_count, eof)? {
133                    Some((fh, cursor))
134                } else {
135                    None
136                };
137                // Yield non-special entries directly — no intermediate Vec.
138                let convert = $convert;
139                let entry_iter = {
140                    let mut current = entries_head;
141                    std::iter::from_fn(move || {
142                        loop {
143                            let mut node = current.take()?;
144                            current = node.nextentry.take();
145                            let name = node.name.0.as_ref();
146                            if name != b"." && name != b".." {
147                                return Some(Ok(convert(node)));
148                            }
149                        }
150                    })
151                };
152                Ok(Some((futures::stream::iter(entry_iter), next)))
153            },
154        )
155        .try_flatten()
156    }};
157}
158
159pub(crate) use paged_dir_stream;
160
161#[allow(dead_code)]
162enum MountProc3 {
163    Null = 0,
164    Mount = 1,
165    Umount = 3,
166    Export = 5,
167}
168
169#[derive(Clone, Copy)]
170enum NFSProc3 {
171    Null = 0,
172    GetAttr = 1,
173    SetAttr = 2,
174    Lookup = 3,
175    Access = 4,
176    Readlink = 5,
177    Read = 6,
178    Write = 7,
179    Create = 8,
180    Mkdir = 9,
181    Symlink = 10,
182    Remove = 12,
183    Rmdir = 13,
184    Rename = 14,
185    Link = 15,
186    Readdir = 16,
187    Readdirplus = 17,
188    FSStat = 18,
189    FSInfo = 19,
190    Pathconf = 20,
191    Commit = 21,
192}
193
194impl NFSProc3 {
195    const fn metadata(&self) -> (&'static str, crate::OperationClass) {
196        match self {
197            Self::Null => ("null", crate::OperationClass::ReadOnly),
198            Self::GetAttr => ("getattr", crate::OperationClass::ReadOnly),
199            Self::SetAttr => ("setattr", crate::OperationClass::ReplaySensitive),
200            Self::Lookup => ("lookup", crate::OperationClass::ReadOnly),
201            Self::Access => ("access", crate::OperationClass::ReadOnly),
202            Self::Readlink => ("readlink", crate::OperationClass::ReadOnly),
203            Self::Read => ("read", crate::OperationClass::ReadOnly),
204            Self::Write => ("write", crate::OperationClass::ReplaySensitive),
205            Self::Create => ("create", crate::OperationClass::ReplaySensitive),
206            Self::Mkdir => ("mkdir", crate::OperationClass::ReplaySensitive),
207            Self::Symlink => ("symlink", crate::OperationClass::ReplaySensitive),
208            Self::Remove => ("remove", crate::OperationClass::ReplaySensitive),
209            Self::Rmdir => ("rmdir", crate::OperationClass::ReplaySensitive),
210            Self::Rename => ("rename", crate::OperationClass::ReplaySensitive),
211            Self::Link => ("link", crate::OperationClass::ReplaySensitive),
212            Self::Readdir => ("readdir", crate::OperationClass::ReadOnly),
213            Self::Readdirplus => ("readdirplus", crate::OperationClass::ReadOnly),
214            Self::FSStat => ("fsstat", crate::OperationClass::ReadOnly),
215            Self::FSInfo => ("fsinfo", crate::OperationClass::ReadOnly),
216            Self::Pathconf => ("pathconf", crate::OperationClass::ReadOnly),
217            Self::Commit => ("commit", crate::OperationClass::ReplaySensitive),
218        }
219    }
220
221    const fn operation_class(&self) -> crate::OperationClass {
222        self.metadata().1
223    }
224
225    const fn operation_name(&self) -> &'static str {
226        self.metadata().0
227    }
228
229    const fn replay_policy(&self) -> crate::rpc::ReplayPolicy {
230        match self.operation_class() {
231            crate::OperationClass::ReadOnly => NFS_REPLAY,
232            crate::OperationClass::SessionControl | crate::OperationClass::ReplaySensitive => {
233                crate::rpc::ReplayPolicy::ONE_ATTEMPT
234            }
235        }
236    }
237
238    fn request_context(&self) -> RequestContext {
239        RequestContext {
240            operation: self.operation_name().to_string(),
241            protocol: crate::NFSVersion::NFSv3,
242            request_id: None,
243        }
244    }
245}
246
247#[cfg(test)]
248mod operation_class_tests {
249    use super::NFSProc3;
250    use crate::OperationClass;
251
252    #[test]
253    fn every_supported_procedure_has_an_explicit_operation_class() {
254        let cases = [
255            (NFSProc3::Null, OperationClass::ReadOnly),
256            (NFSProc3::GetAttr, OperationClass::ReadOnly),
257            (NFSProc3::SetAttr, OperationClass::ReplaySensitive),
258            (NFSProc3::Lookup, OperationClass::ReadOnly),
259            (NFSProc3::Access, OperationClass::ReadOnly),
260            (NFSProc3::Readlink, OperationClass::ReadOnly),
261            (NFSProc3::Read, OperationClass::ReadOnly),
262            (NFSProc3::Write, OperationClass::ReplaySensitive),
263            (NFSProc3::Create, OperationClass::ReplaySensitive),
264            (NFSProc3::Mkdir, OperationClass::ReplaySensitive),
265            (NFSProc3::Symlink, OperationClass::ReplaySensitive),
266            (NFSProc3::Remove, OperationClass::ReplaySensitive),
267            (NFSProc3::Rmdir, OperationClass::ReplaySensitive),
268            (NFSProc3::Rename, OperationClass::ReplaySensitive),
269            (NFSProc3::Link, OperationClass::ReplaySensitive),
270            (NFSProc3::Readdir, OperationClass::ReadOnly),
271            (NFSProc3::Readdirplus, OperationClass::ReadOnly),
272            (NFSProc3::FSStat, OperationClass::ReadOnly),
273            (NFSProc3::FSInfo, OperationClass::ReadOnly),
274            (NFSProc3::Pathconf, OperationClass::ReadOnly),
275            (NFSProc3::Commit, OperationClass::ReplaySensitive),
276        ];
277
278        for (procedure, expected) in cases {
279            assert_eq!(procedure.operation_class(), expected);
280        }
281    }
282
283    #[test]
284    fn replay_sensitive_procedures_never_use_transport_replay() {
285        assert_eq!(
286            NFSProc3::Write.replay_policy(),
287            crate::rpc::ReplayPolicy::ONE_ATTEMPT
288        );
289        assert_eq!(
290            NFSProc3::Rename.replay_policy(),
291            crate::rpc::ReplayPolicy::ONE_ATTEMPT
292        );
293        assert_eq!(NFSProc3::Read.replay_policy(), super::NFS_REPLAY);
294    }
295}
296
297// XDR encoding trait for request argument types.
298trait XdrEncode {
299    fn encode(&self, buf: &mut Vec<u8>);
300}
301
302// Helper encoding functions.
303fn xdr_u32(buf: &mut Vec<u8>, v: u32) {
304    buf.extend_from_slice(&v.to_be_bytes());
305}
306
307fn xdr_u64(buf: &mut Vec<u8>, v: u64) {
308    buf.extend_from_slice(&v.to_be_bytes());
309}
310
311fn xdr_i32(buf: &mut Vec<u8>, v: i32) {
312    buf.extend_from_slice(&v.to_be_bytes());
313}
314
315/// Write `data` followed by padding to the next 4-byte boundary.
316fn xdr_fixed_bytes(buf: &mut Vec<u8>, data: &[u8]) {
317    buf.extend_from_slice(data);
318    let pad = (4 - data.len() % 4) % 4;
319    for _ in 0..pad {
320        buf.push(0);
321    }
322}
323
324/// Write a variable-length XDR opaque: 4-byte length, then data padded to 4-byte boundary.
325fn xdr_var_bytes(buf: &mut Vec<u8>, data: &[u8]) {
326    xdr_u32(buf, data.len() as u32);
327    xdr_fixed_bytes(buf, data);
328}
329
330/// Write an XDR variable-length string (same encoding as opaque).
331fn xdr_string(buf: &mut Vec<u8>, s: &str) {
332    xdr_var_bytes(buf, s.as_bytes());
333}
334
335// Mount phase uses a small retry count for fast failure detection.
336const MOUNT_RETRIES: usize = 2;
337// Post-mount NFS operations use a higher retry count for resilience.
338const NFS_RETRIES: usize = 10;
339const MOUNT_REPLAY: crate::rpc::ReplayPolicy =
340    crate::rpc::ReplayPolicy::byte_identical(MOUNT_RETRIES);
341const NFS_REPLAY: crate::rpc::ReplayPolicy = crate::rpc::ReplayPolicy::byte_identical(NFS_RETRIES);
342// Timeout for metadata operations (LOOKUP, GETATTR, READDIR, etc.).
343const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
344// Base timeout for data operations (READ, WRITE). Scaled up for large payloads.
345const DATA_TIMEOUT_BASE_SECS: u64 = 10;
346// Minimum bandwidth assumed for timeout scaling (10 Mbps = ~1.25 MB/s).
347const MIN_BANDWIDTH_BYTES_PER_SEC: u64 = 1_250_000;
348
349fn data_timeout(data_size: usize) -> std::time::Duration {
350    let transfer_secs = data_size as u64 / MIN_BANDWIDTH_BYTES_PER_SEC;
351    std::time::Duration::from_secs(DATA_TIMEOUT_BASE_SECS + transfer_secs)
352}
353
354// ─── nfs3_call! macro ────────────────────────────────────────────────────────
355//
356// Generates a private async method on Mount that:
357//   1. Packs the RPC header + args into a Vec<u8>.
358//   2. Sends via rpc::Client::call().
359//   3. Decodes the nfsstat3 status from the first 4 bytes.
360//   4. On NFS3_OK, decodes the *resok struct via TryFrom<&mut Bytes>.
361//   5. On error, returns the nfsstat3 error code as an Err.
362
363macro_rules! nfs3_call {
364    ($name:ident, $proc:ident, $args:ty, $resok:ty) => {
365        nfs3_call!($name, $proc, $args, $resok, warn);
366    };
367    ($name:ident, $proc:ident, $args:ty, $resok:ty, $err_level:ident) => {
368        async fn $name(&self, args: $args) -> Result<$resok> {
369            let procedure = NFSProc3::$proc;
370            let operation_class = procedure.operation_class();
371            let context = procedure.request_context();
372            let mut buf = Vec::<u8>::new();
373            self.pack_nfs3(procedure, &args, &mut buf);
374            tracing::debug!(proc = stringify!($proc), "NFS3 call");
375            let mut bytes = self
376                .rpc
377                .call(
378                    buf,
379                    procedure.replay_policy(),
380                    METADATA_TIMEOUT,
381                )
382                .await
383                .map_err(|error| classify_sent_nfs3_error(operation_class, context.clone(), error))?;
384            let status = nfsstat3::try_from(&mut bytes)
385                .map_err(|error| classify_sent_nfs3_error(operation_class, context.clone(), NfsError::Xdr(error.to_string())))?;
386            match status {
387                nfsstat3::NFS3_OK => {
388                    tracing::trace!(proc = stringify!($proc), "NFS3 call succeeded");
389                    <$resok>::try_from(&mut bytes)
390                        .map_err(|error| classify_sent_nfs3_error(operation_class, context, NfsError::Xdr(error.to_string())))
391                }
392                e => {
393                    tracing::$err_level!(proc = stringify!($proc), status = ?e, "NFS3 call returned error status");
394                    Err(NfsError::Nfs3(e))
395                }
396            }
397        }
398    };
399}
400
401// ─── Mount struct ────────────────────────────────────────────────────────────
402
403#[derive(Debug)]
404pub struct Mount {
405    pub(crate) rpc: rpc::Client,
406    pub(crate) auth: Auth,
407    pub(crate) fh: Bytes,
408    pub(crate) dir: String,
409    pub(crate) dircount: u32,
410    pub(crate) maxcount: u32,
411    pub(crate) rsize: u32,
412    pub(crate) wsize: u32,
413}
414
415impl Mount {
416    fn pack_nfs3(&self, proc: NFSProc3, args: &dyn XdrEncode, buf: &mut Vec<u8>) {
417        rpc_header(rpc::NFS_PROG, rpc::NFS3_VERSION, proc as u32, &self.auth).encode(buf);
418        args.encode(buf);
419    }
420
421    pub fn getfh(&self) -> Bytes {
422        self.fh.clone()
423    }
424
425    // Special-cased: NULL returns no body to decode.
426    async fn _null(&self, args: NULL3args) -> Result<()> {
427        let procedure = NFSProc3::Null;
428        let mut buf = Vec::<u8>::new();
429        self.pack_nfs3(procedure, &args, &mut buf);
430        tracing::debug!(proc = "Null", "NFS3 call");
431        let _ = self
432            .rpc
433            .call(buf, procedure.replay_policy(), METADATA_TIMEOUT)
434            .await
435            .map_err(|error| {
436                classify_sent_nfs3_error(
437                    procedure.operation_class(),
438                    procedure.request_context(),
439                    error,
440                )
441            })?;
442        tracing::trace!(proc = "Null", "NFS3 call succeeded");
443        Ok(())
444    }
445
446    nfs3_call!(_access, Access, ACCESS3args, ACCESS3resok);
447    nfs3_call!(_commit, Commit, COMMIT3args, COMMIT3resok);
448    nfs3_call!(_create, Create, CREATE3args, CREATE3resok);
449    nfs3_call!(_fsinfo, FSInfo, FSINFO3args, FSINFO3resok);
450    nfs3_call!(_fsstat, FSStat, FSSTAT3args, FSSTAT3resok);
451    nfs3_call!(_getattr, GetAttr, GETATTR3args, GETATTR3resok);
452    nfs3_call!(_link, Link, LINK3args, LINK3resok);
453    nfs3_call!(_lookup, Lookup, LOOKUP3args, LOOKUP3resok);
454    nfs3_call!(_mkdir, Mkdir, MKDIR3args, MKDIR3resok, info);
455    nfs3_call!(_pathconf, Pathconf, PATHCONF3args, PATHCONF3resok);
456    // _read is special-cased: timeout scales with requested read size.
457    async fn _read(&self, args: READ3args) -> Result<READ3resok> {
458        let procedure = NFSProc3::Read;
459        let operation_class = procedure.operation_class();
460        let context = procedure.request_context();
461        let timeout = data_timeout(args.count as usize);
462        let mut buf = Vec::<u8>::new();
463        self.pack_nfs3(procedure, &args, &mut buf);
464        tracing::debug!(proc = "Read", "NFS3 call");
465        let mut bytes = self
466            .rpc
467            .call(buf, procedure.replay_policy(), timeout)
468            .await
469            .map_err(|error| classify_sent_nfs3_error(operation_class, context.clone(), error))?;
470        let status = nfsstat3::try_from(&mut bytes).map_err(|error| {
471            classify_sent_nfs3_error(
472                operation_class,
473                context.clone(),
474                NfsError::Xdr(error.to_string()),
475            )
476        })?;
477        match status {
478            nfsstat3::NFS3_OK => {
479                tracing::trace!(proc = "Read", "NFS3 call succeeded");
480                READ3resok::try_from(&mut bytes).map_err(|error| {
481                    classify_sent_nfs3_error(
482                        operation_class,
483                        context,
484                        NfsError::Xdr(error.to_string()),
485                    )
486                })
487            }
488            e => {
489                tracing::warn!(proc = "Read", status = ?e, "NFS3 call returned error status");
490                Err(NfsError::Nfs3(e))
491            }
492        }
493    }
494    nfs3_call!(_readdir, Readdir, READDIR3args, READDIR3resok);
495    nfs3_call!(
496        _readdirplus,
497        Readdirplus,
498        READDIRPLUS3args,
499        READDIRPLUS3resok
500    );
501    nfs3_call!(_readlink, Readlink, READLINK3args, READLINK3resok);
502    nfs3_call!(_remove, Remove, REMOVE3args, REMOVE3resok);
503    nfs3_call!(_rename, Rename, RENAME3args, RENAME3resok);
504    nfs3_call!(_rmdir, Rmdir, RMDIR3args, RMDIR3resok);
505    nfs3_call!(_setattr, SetAttr, SETATTR3args, SETATTR3resok);
506    nfs3_call!(_symlink, Symlink, SYMLINK3args, SYMLINK3resok);
507    // _write is special-cased to avoid copying the write payload into the request buffer.
508    // WRITE3args::encode writes only the XDR length prefix; the raw data is passed to
509    // call_with_data which appends it directly to the TCP stream.
510    async fn _write(&self, args: WRITE3args) -> Result<WRITE3resok> {
511        let procedure = NFSProc3::Write;
512        let operation_class = procedure.operation_class();
513        let context = procedure.request_context();
514        let data = args.data.clone();
515        let timeout = data_timeout(data.len());
516        let mut buf = Vec::<u8>::new();
517        self.pack_nfs3(procedure, &args, &mut buf);
518        tracing::debug!(proc = "Write", data_len = data.len(), "NFS3 call");
519        let mut bytes = self
520            .rpc
521            .call_with_data(buf, data, procedure.replay_policy(), timeout)
522            .await
523            .map_err(|error| classify_sent_nfs3_error(operation_class, context.clone(), error))?;
524        let status = nfsstat3::try_from(&mut bytes).map_err(|error| {
525            classify_sent_nfs3_error(
526                operation_class,
527                context.clone(),
528                NfsError::Xdr(error.to_string()),
529            )
530        })?;
531        match status {
532            nfsstat3::NFS3_OK => {
533                tracing::trace!(proc = "Write", "NFS3 call succeeded");
534                WRITE3resok::try_from(&mut bytes).map_err(|error| {
535                    classify_sent_nfs3_error(
536                        operation_class,
537                        context,
538                        NfsError::Xdr(error.to_string()),
539                    )
540                })
541            }
542            e => {
543                tracing::warn!(proc = "Write", status = ?e, "NFS3 call returned error status");
544                Err(NfsError::Nfs3(e))
545            }
546        }
547    }
548}
549
550pub(crate) fn rpc_header(prog: u32, vers: u32, proc: u32, cred: &Auth) -> rpc::Header {
551    rpc::Header::new(rpc::RPC_VERSION, prog, vers, proc, cred, &Auth::new_null())
552}
553
554// ─── Request argument types ──────────────────────────────────────────────────
555// These are encoding-only types used to build NFS3 wire requests.
556// Response types come from the fastxdr module (TryFrom<Bytes>).
557
558#[allow(non_camel_case_types)]
559#[derive(Debug, PartialEq)]
560pub(crate) struct nfs_fh3 {
561    pub(crate) data: Bytes,
562}
563
564impl XdrEncode for nfs_fh3 {
565    fn encode(&self, buf: &mut Vec<u8>) {
566        xdr_var_bytes(buf, &self.data);
567    }
568}
569
570#[allow(non_camel_case_types)]
571#[derive(Debug, PartialEq)]
572pub(crate) struct filename3(pub(crate) String);
573
574impl XdrEncode for filename3 {
575    fn encode(&self, buf: &mut Vec<u8>) {
576        xdr_string(buf, &self.0);
577    }
578}
579
580#[allow(non_camel_case_types)]
581#[derive(Debug, PartialEq)]
582pub(crate) struct nfspath3(pub(crate) String);
583
584impl XdrEncode for nfspath3 {
585    fn encode(&self, buf: &mut Vec<u8>) {
586        xdr_string(buf, &self.0);
587    }
588}
589
590#[allow(non_camel_case_types)]
591#[derive(Debug, PartialEq)]
592pub(crate) struct diropargs3 {
593    pub(crate) dir: nfs_fh3,
594    pub(crate) name: filename3,
595}
596
597impl XdrEncode for diropargs3 {
598    fn encode(&self, buf: &mut Vec<u8>) {
599        self.dir.encode(buf);
600        self.name.encode(buf);
601    }
602}
603
604// sattr3 field types — match the old nfs3xdr.rs enums.
605#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
606#[derive(Debug, PartialEq)]
607pub(crate) enum set_mode3 {
608    TRUE(u32),
609    default,
610}
611
612#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
613#[derive(Debug, PartialEq)]
614pub(crate) enum set_uid3 {
615    TRUE(u32),
616    default,
617}
618
619#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
620#[derive(Debug, PartialEq)]
621pub(crate) enum set_gid3 {
622    TRUE(u32),
623    default,
624}
625
626#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
627#[derive(Debug, PartialEq)]
628pub(crate) enum set_size3 {
629    TRUE(u64),
630    default,
631}
632
633#[allow(non_camel_case_types)]
634#[derive(Debug, PartialEq)]
635pub(crate) enum set_atime {
636    SET_TO_CLIENT_TIME(nfstime3_req),
637    default,
638}
639
640#[allow(non_camel_case_types)]
641#[derive(Debug, PartialEq)]
642pub(crate) enum set_mtime {
643    SET_TO_CLIENT_TIME(nfstime3_req),
644    default,
645}
646
647/// nfstime3 used for *encoding* in requests (distinct from the fastxdr decoding type).
648#[allow(non_camel_case_types)]
649#[derive(Debug, PartialEq)]
650pub(crate) struct nfstime3_req {
651    pub(crate) seconds: u32,
652    pub(crate) nseconds: u32,
653}
654
655#[allow(non_camel_case_types)]
656#[derive(Debug, PartialEq)]
657pub(crate) struct sattr3 {
658    pub(crate) mode: set_mode3,
659    pub(crate) uid: set_uid3,
660    pub(crate) gid: set_gid3,
661    pub(crate) size: set_size3,
662    pub(crate) atime: set_atime,
663    pub(crate) mtime: set_mtime,
664}
665
666impl Default for sattr3 {
667    fn default() -> Self {
668        Self {
669            mode: set_mode3::default,
670            uid: set_uid3::default,
671            gid: set_gid3::default,
672            size: set_size3::default,
673            atime: set_atime::default,
674            mtime: set_mtime::default,
675        }
676    }
677}
678
679impl XdrEncode for sattr3 {
680    fn encode(&self, buf: &mut Vec<u8>) {
681        // mode
682        match &self.mode {
683            set_mode3::TRUE(v) => {
684                xdr_i32(buf, 1);
685                xdr_u32(buf, *v);
686            }
687            set_mode3::default => xdr_i32(buf, 0),
688        }
689        // uid
690        match &self.uid {
691            set_uid3::TRUE(v) => {
692                xdr_i32(buf, 1);
693                xdr_u32(buf, *v);
694            }
695            set_uid3::default => xdr_i32(buf, 0),
696        }
697        // gid
698        match &self.gid {
699            set_gid3::TRUE(v) => {
700                xdr_i32(buf, 1);
701                xdr_u32(buf, *v);
702            }
703            set_gid3::default => xdr_i32(buf, 0),
704        }
705        // size
706        match &self.size {
707            set_size3::TRUE(v) => {
708                xdr_i32(buf, 1);
709                xdr_u64(buf, *v);
710            }
711            set_size3::default => xdr_i32(buf, 0),
712        }
713        // atime
714        match &self.atime {
715            set_atime::SET_TO_CLIENT_TIME(t) => {
716                xdr_i32(buf, 2); // SET_TO_CLIENT_TIME
717                xdr_u32(buf, t.seconds);
718                xdr_u32(buf, t.nseconds);
719            }
720            set_atime::default => xdr_i32(buf, 0),
721        }
722        // mtime
723        match &self.mtime {
724            set_mtime::SET_TO_CLIENT_TIME(t) => {
725                xdr_i32(buf, 2); // SET_TO_CLIENT_TIME
726                xdr_u32(buf, t.seconds);
727                xdr_u32(buf, t.nseconds);
728            }
729            set_mtime::default => xdr_i32(buf, 0),
730        }
731    }
732}
733
734#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
735#[derive(Debug, PartialEq)]
736pub(crate) enum sattrguard3 {
737    TRUE(nfstime3_req),
738    FALSE,
739}
740
741impl XdrEncode for sattrguard3 {
742    fn encode(&self, buf: &mut Vec<u8>) {
743        match self {
744            sattrguard3::TRUE(t) => {
745                xdr_i32(buf, 1);
746                xdr_u32(buf, t.seconds);
747                xdr_u32(buf, t.nseconds);
748            }
749            sattrguard3::FALSE => xdr_i32(buf, 0),
750        }
751    }
752}
753
754#[allow(non_camel_case_types)]
755#[derive(Debug, PartialEq)]
756pub(crate) struct symlinkdata3 {
757    pub(crate) symlink_attributes: sattr3,
758    pub(crate) symlink_data: nfspath3,
759}
760
761impl XdrEncode for symlinkdata3 {
762    fn encode(&self, buf: &mut Vec<u8>) {
763        self.symlink_attributes.encode(buf);
764        self.symlink_data.encode(buf);
765    }
766}
767
768#[allow(non_camel_case_types, dead_code, clippy::upper_case_acronyms)]
769#[derive(Debug, PartialEq)]
770pub(crate) enum createhow3 {
771    UNCHECKED(sattr3),
772    GUARDED(sattr3),
773    EXCLUSIVE([u8; 8]),
774}
775
776impl XdrEncode for createhow3 {
777    fn encode(&self, buf: &mut Vec<u8>) {
778        match self {
779            createhow3::UNCHECKED(a) => {
780                xdr_i32(buf, 0); // UNCHECKED
781                a.encode(buf);
782            }
783            createhow3::GUARDED(a) => {
784                xdr_i32(buf, 1); // GUARDED
785                a.encode(buf);
786            }
787            createhow3::EXCLUSIVE(v) => {
788                xdr_i32(buf, 2); // EXCLUSIVE
789                buf.extend_from_slice(v);
790            }
791        }
792    }
793}
794
795// ─── NFS3 argument structs ───────────────────────────────────────────────────
796
797#[derive(Debug, PartialEq)]
798pub(crate) struct NULL3args {}
799
800impl XdrEncode for NULL3args {
801    fn encode(&self, _buf: &mut Vec<u8>) {}
802}
803
804#[derive(Debug, PartialEq)]
805pub(crate) struct GETATTR3args {
806    pub(crate) object: nfs_fh3,
807}
808
809impl XdrEncode for GETATTR3args {
810    fn encode(&self, buf: &mut Vec<u8>) {
811        self.object.encode(buf);
812    }
813}
814
815#[derive(Debug, PartialEq)]
816pub(crate) struct ACCESS3args {
817    pub(crate) object: nfs_fh3,
818    pub(crate) access: u32,
819}
820
821impl XdrEncode for ACCESS3args {
822    fn encode(&self, buf: &mut Vec<u8>) {
823        self.object.encode(buf);
824        xdr_u32(buf, self.access);
825    }
826}
827
828#[derive(Debug, PartialEq)]
829pub(crate) struct READLINK3args {
830    pub(crate) symlink: nfs_fh3,
831}
832
833impl XdrEncode for READLINK3args {
834    fn encode(&self, buf: &mut Vec<u8>) {
835        self.symlink.encode(buf);
836    }
837}
838
839#[derive(Debug, PartialEq)]
840pub(crate) struct READ3args {
841    pub(crate) file: nfs_fh3,
842    pub(crate) offset: u64,
843    pub(crate) count: u32,
844}
845
846impl XdrEncode for READ3args {
847    fn encode(&self, buf: &mut Vec<u8>) {
848        self.file.encode(buf);
849        xdr_u64(buf, self.offset);
850        xdr_u32(buf, self.count);
851    }
852}
853
854#[derive(Debug, PartialEq)]
855pub(crate) struct WRITE3args {
856    pub(crate) file: nfs_fh3,
857    pub(crate) offset: u64,
858    pub(crate) count: u32,
859    pub(crate) stable: WriteStable,
860    pub(crate) data: Bytes,
861}
862
863#[allow(dead_code)]
864#[derive(Debug, PartialEq)]
865pub(crate) enum WriteStable {
866    Unstable = 0,
867    DataSync = 1,
868    FileSync = 2,
869}
870
871impl XdrEncode for WRITE3args {
872    fn encode(&self, buf: &mut Vec<u8>) {
873        self.file.encode(buf);
874        xdr_u64(buf, self.offset);
875        xdr_u32(buf, self.count);
876        xdr_i32(buf, self.stable.as_i32());
877        // Write only the XDR opaque length prefix here. The raw payload bytes are
878        // sent separately by call_with_data to avoid copying the user buffer.
879        xdr_u32(buf, self.data.len() as u32);
880    }
881}
882
883impl WriteStable {
884    fn as_i32(&self) -> i32 {
885        match self {
886            WriteStable::Unstable => 0,
887            WriteStable::DataSync => 1,
888            WriteStable::FileSync => 2,
889        }
890    }
891}
892
893#[derive(Debug, PartialEq)]
894pub(crate) struct CREATE3args {
895    pub(crate) where_: diropargs3,
896    pub(crate) how: createhow3,
897}
898
899impl XdrEncode for CREATE3args {
900    fn encode(&self, buf: &mut Vec<u8>) {
901        self.where_.encode(buf);
902        self.how.encode(buf);
903    }
904}
905
906#[derive(Debug, PartialEq)]
907pub(crate) struct MKDIR3args {
908    pub(crate) where_: diropargs3,
909    pub(crate) attrs: sattr3,
910}
911
912impl XdrEncode for MKDIR3args {
913    fn encode(&self, buf: &mut Vec<u8>) {
914        self.where_.encode(buf);
915        self.attrs.encode(buf);
916    }
917}
918
919#[derive(Debug, PartialEq)]
920pub(crate) struct SYMLINK3args {
921    pub(crate) where_: diropargs3,
922    pub(crate) symlink: symlinkdata3,
923}
924
925impl XdrEncode for SYMLINK3args {
926    fn encode(&self, buf: &mut Vec<u8>) {
927        self.where_.encode(buf);
928        self.symlink.encode(buf);
929    }
930}
931
932#[derive(Debug, PartialEq)]
933pub(crate) struct REMOVE3args {
934    pub(crate) object: diropargs3,
935}
936
937impl XdrEncode for REMOVE3args {
938    fn encode(&self, buf: &mut Vec<u8>) {
939        self.object.encode(buf);
940    }
941}
942
943#[derive(Debug, PartialEq)]
944pub(crate) struct RMDIR3args {
945    pub(crate) object: diropargs3,
946}
947
948impl XdrEncode for RMDIR3args {
949    fn encode(&self, buf: &mut Vec<u8>) {
950        self.object.encode(buf);
951    }
952}
953
954#[derive(Debug, PartialEq)]
955pub(crate) struct RENAME3args {
956    pub(crate) from: diropargs3,
957    pub(crate) to: diropargs3,
958}
959
960impl XdrEncode for RENAME3args {
961    fn encode(&self, buf: &mut Vec<u8>) {
962        self.from.encode(buf);
963        self.to.encode(buf);
964    }
965}
966
967#[derive(Debug, PartialEq)]
968pub(crate) struct LINK3args {
969    pub(crate) file: nfs_fh3,
970    pub(crate) link: diropargs3,
971}
972
973impl XdrEncode for LINK3args {
974    fn encode(&self, buf: &mut Vec<u8>) {
975        self.file.encode(buf);
976        self.link.encode(buf);
977    }
978}
979
980#[derive(Debug, PartialEq)]
981pub(crate) struct READDIR3args {
982    pub(crate) dir: nfs_fh3,
983    pub(crate) cookie: u64,
984    pub(crate) cookieverf: [u8; 8],
985    pub(crate) count: u32,
986}
987
988impl XdrEncode for READDIR3args {
989    fn encode(&self, buf: &mut Vec<u8>) {
990        self.dir.encode(buf);
991        xdr_u64(buf, self.cookie);
992        buf.extend_from_slice(&self.cookieverf);
993        xdr_u32(buf, self.count);
994    }
995}
996
997#[derive(Debug, PartialEq)]
998pub(crate) struct READDIRPLUS3args {
999    pub(crate) dir: nfs_fh3,
1000    pub(crate) cookie: u64,
1001    pub(crate) cookieverf: [u8; 8],
1002    pub(crate) dircount: u32,
1003    pub(crate) maxcount: u32,
1004}
1005
1006impl XdrEncode for READDIRPLUS3args {
1007    fn encode(&self, buf: &mut Vec<u8>) {
1008        self.dir.encode(buf);
1009        xdr_u64(buf, self.cookie);
1010        buf.extend_from_slice(&self.cookieverf);
1011        xdr_u32(buf, self.dircount);
1012        xdr_u32(buf, self.maxcount);
1013    }
1014}
1015
1016#[derive(Debug, PartialEq)]
1017pub(crate) struct FSSTAT3args {
1018    pub(crate) fsroot: nfs_fh3,
1019}
1020
1021impl XdrEncode for FSSTAT3args {
1022    fn encode(&self, buf: &mut Vec<u8>) {
1023        self.fsroot.encode(buf);
1024    }
1025}
1026
1027#[derive(Debug, PartialEq)]
1028pub(crate) struct FSINFO3args {
1029    pub(crate) fsroot: nfs_fh3,
1030}
1031
1032impl XdrEncode for FSINFO3args {
1033    fn encode(&self, buf: &mut Vec<u8>) {
1034        self.fsroot.encode(buf);
1035    }
1036}
1037
1038#[derive(Debug, PartialEq)]
1039pub(crate) struct PATHCONF3args {
1040    pub(crate) object: nfs_fh3,
1041}
1042
1043impl XdrEncode for PATHCONF3args {
1044    fn encode(&self, buf: &mut Vec<u8>) {
1045        self.object.encode(buf);
1046    }
1047}
1048
1049#[derive(Debug, PartialEq)]
1050pub(crate) struct COMMIT3args {
1051    pub(crate) file: nfs_fh3,
1052    pub(crate) offset: u64,
1053    pub(crate) count: u32,
1054}
1055
1056impl XdrEncode for COMMIT3args {
1057    fn encode(&self, buf: &mut Vec<u8>) {
1058        self.file.encode(buf);
1059        xdr_u64(buf, self.offset);
1060        xdr_u32(buf, self.count);
1061    }
1062}
1063
1064#[derive(Debug, PartialEq)]
1065pub(crate) struct SETATTR3args {
1066    pub(crate) object: nfs_fh3,
1067    pub(crate) new_attributes: sattr3,
1068    pub(crate) guard: sattrguard3,
1069}
1070
1071impl XdrEncode for SETATTR3args {
1072    fn encode(&self, buf: &mut Vec<u8>) {
1073        self.object.encode(buf);
1074        self.new_attributes.encode(buf);
1075        self.guard.encode(buf);
1076    }
1077}
1078
1079#[derive(Debug, PartialEq)]
1080pub(crate) struct LOOKUP3args {
1081    pub(crate) what: diropargs3,
1082}
1083
1084impl XdrEncode for LOOKUP3args {
1085    fn encode(&self, buf: &mut Vec<u8>) {
1086        self.what.encode(buf);
1087    }
1088}
1089
1090// ─── MOUNT protocol argument encoding ────────────────────────────────────────
1091
1092/// Encode a MOUNT dirpath argument (variable-length string).
1093pub(crate) fn encode_dirpath(buf: &mut Vec<u8>, path: &str) {
1094    xdr_string(buf, path);
1095}
1096
1097// ─── From/Into conversions ────────────────────────────────────────────────────
1098
1099impl From<nfstime3> for Time {
1100    fn from(time: nfstime3) -> Self {
1101        Self {
1102            seconds: time.seconds,
1103            nseconds: time.nseconds,
1104        }
1105    }
1106}
1107
1108impl From<fattr3> for crate::mount::Attr {
1109    fn from(attr: fattr3) -> Self {
1110        Self {
1111            type_: attr.type_v as u32,
1112            file_mode: attr.mode.0,
1113            nlink: attr.nlink,
1114            uid: attr.uid.0,
1115            gid: attr.gid.0,
1116            filesize: attr.size.0,
1117            used: attr.used.0,
1118            spec_data: [attr.rdev.specdata1, attr.rdev.specdata2],
1119            fsid: attr.fsid,
1120            fileid: attr.fileid.0,
1121            atime: attr.atime.into(),
1122            mtime: attr.mtime.into(),
1123            ctime: attr.ctime.into(),
1124            acl: None,
1125            owner: String::new(),
1126            owner_group: String::new(),
1127            filehandle: Bytes::new(),
1128        }
1129    }
1130}
1131
1132impl From<post_op_attr> for Option<crate::mount::Attr> {
1133    fn from(attr: post_op_attr) -> Self {
1134        match attr {
1135            post_op_attr::TRUE(a) => Some(a.into()),
1136            post_op_attr::FALSE => None,
1137        }
1138    }
1139}
1140
1141impl From<FSINFO3resok> for crate::mount::FSInfo {
1142    fn from(ok: FSINFO3resok) -> Self {
1143        Self {
1144            attr: ok.obj_attributes.into(),
1145            rtmax: ok.rtmax,
1146            rtpref: ok.rtpref,
1147            rtmult: ok.rtmult,
1148            wtmax: ok.wtmax,
1149            wtpref: ok.wtpref,
1150            wtmult: ok.wtmult,
1151            dtpref: ok.dtpref,
1152            maxfilesize: ok.maxfilesize.0,
1153            time_delta: ok.time_delta.into(),
1154            properties: ok.properties,
1155        }
1156    }
1157}
1158
1159impl From<FSSTAT3resok> for crate::mount::FSStat {
1160    fn from(ok: FSSTAT3resok) -> Self {
1161        Self {
1162            attr: ok.obj_attributes.into(),
1163            tbytes: ok.tbytes.0,
1164            fbytes: ok.fbytes.0,
1165            abytes: ok.abytes.0,
1166            tfiles: ok.tfiles.0,
1167            ffiles: ok.ffiles.0,
1168            afiles: ok.afiles.0,
1169            invarsec: ok.invarsec,
1170        }
1171    }
1172}
1173
1174impl From<PATHCONF3resok> for crate::mount::Pathconf {
1175    fn from(ok: PATHCONF3resok) -> Self {
1176        Self {
1177            attr: ok.obj_attributes.into(),
1178            linkmax: ok.linkmax,
1179            name_max: ok.name_max,
1180            no_trunc: ok.no_trunc,
1181            chown_restricted: ok.chown_restricted,
1182            case_insensitive: ok.case_insensitive,
1183            case_preserving: ok.case_preserving,
1184        }
1185    }
1186}
1187
1188/// Extract a Bytes file handle from a post_op_fh3.
1189pub(crate) fn from_post_op_fh3(pofh: post_op_fh3) -> Result<Bytes> {
1190    match pofh {
1191        post_op_fh3::TRUE(fh) => Ok(fh.0),
1192        post_op_fh3::FALSE => Err(NfsError::Rpc("bad file handle".to_string())),
1193    }
1194}
1195
1196#[allow(unused)]
1197pub use fastxdr::nfsstat3 as ErrorCode;
1198
1199impl std::error::Error for ErrorCode {}
1200
1201impl std::fmt::Display for ErrorCode {
1202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1203        match self {
1204            ErrorCode::NFS3_OK => write!(f, "call completed successfully"),
1205            ErrorCode::NFS3ERR_PERM => write!(f, "permission denied"),
1206            ErrorCode::NFS3ERR_NOENT => write!(f, "no such file or directory"),
1207            ErrorCode::NFS3ERR_NXIO => write!(f, "i/o error - no such device or address"),
1208            ErrorCode::NFS3ERR_ACCES => write!(f, "access denied"),
1209            ErrorCode::NFS3ERR_EXIST => write!(f, "file exists"),
1210            ErrorCode::NFS3ERR_XDEV => write!(f, "cross-device hard link not allowed"),
1211            ErrorCode::NFS3ERR_NODEV => write!(f, "no such device"),
1212            ErrorCode::NFS3ERR_NOTDIR => write!(f, "not a directory"),
1213            ErrorCode::NFS3ERR_ISDIR => write!(f, "is a directory"),
1214            ErrorCode::NFS3ERR_INVAL => write!(f, "invalid or unsupported argument"),
1215            ErrorCode::NFS3ERR_FBIG => write!(f, "file too large"),
1216            ErrorCode::NFS3ERR_NOSPC => write!(f, "no space left on device"),
1217            ErrorCode::NFS3ERR_ROFS => write!(f, "read-only file system"),
1218            ErrorCode::NFS3ERR_MLINK => write!(f, "too many hard links"),
1219            ErrorCode::NFS3ERR_NAMETOOLONG => write!(f, "name is too long"),
1220            ErrorCode::NFS3ERR_NOTEMPTY => write!(f, "directory not empty"),
1221            ErrorCode::NFS3ERR_DQUOT => write!(f, "resource (quota) hard limit exceeded"),
1222            ErrorCode::NFS3ERR_STALE => write!(f, "invalid file handle"),
1223            ErrorCode::NFS3ERR_REMOTE => write!(f, "too many levels of remote in path"),
1224            ErrorCode::NFS3ERR_BADHANDLE => write!(f, "illegal NFS file handle"),
1225            ErrorCode::NFS3ERR_NOT_SYNC => write!(f, "update synchronization mismatch"),
1226            ErrorCode::NFS3ERR_BAD_COOKIE => write!(f, "cookie is stale"),
1227            ErrorCode::NFS3ERR_NOTSUPP => write!(f, "operation is not supported"),
1228            ErrorCode::NFS3ERR_TOOSMALL => write!(f, "buffer or request is too small"),
1229            ErrorCode::NFS3ERR_SERVERFAULT => write!(f, "internal server error"),
1230            ErrorCode::NFS3ERR_BADTYPE => write!(f, "type not supported by server"),
1231            ErrorCode::NFS3ERR_JUKEBOX => write!(f, "try again"),
1232            ErrorCode::NFS3ERR_IO => write!(
1233                f,
1234                "i/o error occurred while processing the requested operation"
1235            ),
1236        }
1237    }
1238}
1239
1240#[allow(unused)]
1241pub use fastxdr::mount_mountstat3 as MountErrorCode;
1242
1243impl std::error::Error for MountErrorCode {}
1244
1245impl std::fmt::Display for MountErrorCode {
1246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247        match self {
1248            MountErrorCode::MNT3_OK => write!(f, "call completed successfully"),
1249            MountErrorCode::MNT3ERR_PERM => write!(f, "permission denied"),
1250            MountErrorCode::MNT3ERR_NOENT => write!(f, "no such file or directory"),
1251            MountErrorCode::MNT3ERR_ACCES => write!(f, "access denied"),
1252            MountErrorCode::MNT3ERR_NOTDIR => write!(f, "not a directory"),
1253            MountErrorCode::MNT3ERR_INVAL => write!(f, "invalid or unsupported argument"),
1254            MountErrorCode::MNT3ERR_NAMETOOLONG => write!(f, "name is too long"),
1255            MountErrorCode::MNT3ERR_NOTSUPP => write!(f, "operation is not supported"),
1256            MountErrorCode::MNT3ERR_SERVERFAULT => write!(f, "internal server error"),
1257            MountErrorCode::MNT3ERR_IO => write!(
1258                f,
1259                "i/o error occurred while processing the requested operation"
1260            ),
1261        }
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    #[test]
1270    fn rpc_header_util() {
1271        let auth = crate::Auth::new_unix("machinist", 123, 987);
1272        let header = rpc_header(9, 8, 7, &auth);
1273        let expected = rpc::Header::new(rpc::RPC_VERSION, 9, 8, 7, &auth, &crate::Auth::new_null());
1274        assert_eq!(header, expected);
1275    }
1276
1277    // ─── XDR encoding tests ──────────────────────────────────────────
1278
1279    #[test]
1280    fn xdr_u32_encodes_big_endian() {
1281        let mut buf = Vec::new();
1282        xdr_u32(&mut buf, 0x01020304);
1283        assert_eq!(buf, [0x01, 0x02, 0x03, 0x04]);
1284    }
1285
1286    #[test]
1287    fn xdr_u64_encodes_big_endian() {
1288        let mut buf = Vec::new();
1289        xdr_u64(&mut buf, 0x0102030405060708);
1290        assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
1291    }
1292
1293    #[test]
1294    fn xdr_i32_encodes_negative() {
1295        let mut buf = Vec::new();
1296        xdr_i32(&mut buf, -1);
1297        assert_eq!(buf, [0xFF, 0xFF, 0xFF, 0xFF]);
1298    }
1299
1300    #[test]
1301    fn xdr_fixed_bytes_no_padding() {
1302        let mut buf = Vec::new();
1303        xdr_fixed_bytes(&mut buf, &[1, 2, 3, 4]); // 4 bytes, no padding needed
1304        assert_eq!(buf, [1, 2, 3, 4]);
1305    }
1306
1307    #[test]
1308    fn xdr_fixed_bytes_with_padding() {
1309        let mut buf = Vec::new();
1310        xdr_fixed_bytes(&mut buf, &[1, 2, 3]); // 3 bytes → 1 byte padding
1311        assert_eq!(buf, [1, 2, 3, 0]);
1312
1313        let mut buf = Vec::new();
1314        xdr_fixed_bytes(&mut buf, &[1, 2]); // 2 bytes → 2 bytes padding
1315        assert_eq!(buf, [1, 2, 0, 0]);
1316
1317        let mut buf = Vec::new();
1318        xdr_fixed_bytes(&mut buf, &[1]); // 1 byte → 3 bytes padding
1319        assert_eq!(buf, [1, 0, 0, 0]);
1320    }
1321
1322    #[test]
1323    fn xdr_fixed_bytes_empty() {
1324        let mut buf = Vec::new();
1325        xdr_fixed_bytes(&mut buf, &[]);
1326        assert!(buf.is_empty());
1327    }
1328
1329    #[test]
1330    fn xdr_var_bytes_encodes_length_prefix_and_padding() {
1331        let mut buf = Vec::new();
1332        xdr_var_bytes(&mut buf, b"hello"); // 5 bytes → length(4) + data(5) + pad(3) = 12
1333        assert_eq!(buf.len(), 12);
1334        assert_eq!(&buf[0..4], &5u32.to_be_bytes()); // length = 5
1335        assert_eq!(&buf[4..9], b"hello");
1336        assert_eq!(&buf[9..12], &[0, 0, 0]); // padding
1337    }
1338
1339    #[test]
1340    fn xdr_var_bytes_empty() {
1341        let mut buf = Vec::new();
1342        xdr_var_bytes(&mut buf, &[]);
1343        assert_eq!(buf, [0, 0, 0, 0]); // length = 0, no data, no padding
1344    }
1345
1346    #[test]
1347    fn xdr_string_encodes_as_var_bytes() {
1348        let mut buf1 = Vec::new();
1349        xdr_string(&mut buf1, "test");
1350        let mut buf2 = Vec::new();
1351        xdr_var_bytes(&mut buf2, b"test");
1352        assert_eq!(buf1, buf2);
1353    }
1354
1355    // ─── bytes_to_string tests ───────────────────────────────────────
1356
1357    #[test]
1358    fn bytes_to_string_valid_utf8() {
1359        let b = Bytes::from("hello world");
1360        assert_eq!(bytes_to_string(b), "hello world");
1361    }
1362
1363    #[test]
1364    fn bytes_to_string_valid_utf8_unicode() {
1365        let b = Bytes::from("日本語テスト");
1366        assert_eq!(bytes_to_string(b), "日本語テスト");
1367    }
1368
1369    #[test]
1370    fn bytes_to_string_empty() {
1371        let b = Bytes::new();
1372        assert_eq!(bytes_to_string(b), "");
1373    }
1374
1375    #[test]
1376    fn bytes_to_string_invalid_utf8_lossy() {
1377        let b = Bytes::from_static(&[0xFF, 0xFE, 0x68, 0x69]); // invalid + "hi"
1378        let result = bytes_to_string(b);
1379        assert!(result.contains("hi"));
1380        assert!(result.contains('\u{FFFD}')); // replacement character
1381    }
1382
1383    // ─── hex_preview tests ───────────────────────────────────────────
1384
1385    #[test]
1386    fn hex_preview_short() {
1387        assert_eq!(hex_preview(&[0xAB, 0xCD, 0xEF]), "ab cd ef");
1388    }
1389
1390    #[test]
1391    fn hex_preview_truncates_at_32() {
1392        let data: Vec<u8> = (0..64).collect();
1393        let result = hex_preview(&data);
1394        assert!(result.ends_with("..."));
1395        // 32 bytes * 3 chars ("xx ") - 1 trailing space + 3 "..." = ~98 chars
1396        assert!(!result.contains("20")); // byte 0x20 = 32, should be truncated
1397    }
1398
1399    #[test]
1400    fn hex_preview_empty() {
1401        assert_eq!(hex_preview(&[]), "");
1402    }
1403}