Skip to main content

nfs_rs/
lib.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
17//! An asynchronous, pure Rust client library for NFSv3 and NFSv4.1.
18//!
19//! Start with [`parse_url_and_mount`] to connect to an NFS export. The returned
20//! [`Mount`] trait object provides file, directory, metadata, link, and
21//! filesystem operations shared across the supported protocol versions.
22//!
23//! ```no_run
24//! use bytes::Bytes;
25//! use nfs_rs::{OPEN_READ, Result, parse_url_and_mount};
26//!
27//! # async fn example() -> Result<()> {
28//! let mount = parse_url_and_mount(
29//!     "nfs://127.0.0.1/some/export?version=4.1&noresvport=true",
30//! )
31//! .await?;
32//!
33//! let created = mount.create_path("hello.txt", Some(0o644)).await?;
34//! nfs_rs::write_all(&*mount, created.fh.clone(), 0, Bytes::from_static(b"hello NFS"))
35//!     .await?;
36//! mount.close(created.fh).await?;
37//!
38//! let opened = mount.open_path("hello.txt", OPEN_READ).await?;
39//! let contents = mount.read(opened.fh.clone(), 0, 9).await?;
40//! mount.close(opened.fh).await?;
41//! assert_eq!(&contents[..], b"hello NFS");
42//!
43//! mount.umount().await?;
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! By default the client binds a privileged source port. Add
49//! `noresvport=true` when the server export accepts non-privileged source
50//! ports.
51
52use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
53use tokio::net::{TcpSocket, TcpStream};
54
55fn privileged_port_candidates(ports: &[u16], start: usize) -> Vec<u16> {
56    (0..ports.len())
57        .map(|offset| ports[(start + offset) % ports.len()])
58        .collect()
59}
60
61fn is_privileged_port_collision(error: &std::io::Error) -> bool {
62    matches!(
63        error.kind(),
64        std::io::ErrorKind::AddrInUse | std::io::ErrorKind::AddrNotAvailable
65    )
66}
67
68pub(crate) async fn connect_to_target(addr: &SocketAddr, noresvport: bool) -> Result<TcpStream> {
69    // Bind to a random privileged port (< 1024), required by some NFS servers.
70    // Exclude well-known service ports to avoid conflicts.
71    const WELL_KNOWN_PORTS: &[u16] = &[
72        1,   // tcpmux
73        7,   // echo
74        9,   // discard
75        11,  // systat
76        13,  // daytime
77        15,  // netstat
78        20,  // ftp-data
79        21,  // ftp
80        22,  // ssh
81        23,  // telnet
82        25,  // smtp
83        37,  // time
84        42,  // nameserver
85        43,  // whois
86        49,  // tacacs
87        53,  // dns
88        67,  // dhcp server
89        68,  // dhcp client
90        69,  // tftp
91        70,  // gopher
92        79,  // finger
93        80,  // http
94        88,  // kerberos
95        102, // iso-tsap
96        110, // pop3
97        111, // rpcbind/portmapper
98        119, // nntp
99        123, // ntp
100        135, // msrpc
101        137, // netbios-ns
102        138, // netbios-dgm
103        139, // netbios-ssn
104        143, // imap
105        161, // snmp
106        162, // snmp-trap
107        179, // bgp
108        389, // ldap
109        427, // svrloc
110        443, // https
111        445, // microsoft-ds
112        464, // kpasswd
113        465, // smtps
114        514, // syslog
115        515, // printer
116        520, // rip
117        530, // rpc
118        543, // klogin
119        544, // kshell
120        546, // dhcpv6-client
121        547, // dhcpv6-server
122        548, // afp
123        554, // rtsp
124        587, // submission
125        593, // http-rpc-epmap
126        631, // ipp
127        636, // ldaps
128        873, // rsync
129        990, // ftps
130        993, // imaps
131        995, // pop3s
132    ];
133    let local_addr_base = if addr.is_ipv4() {
134        SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
135    } else {
136        SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0)
137    };
138    // noresvport=true: 让 OS 选临时端口(Windows ~49152-65535, Linux ~32768-60999)。
139    // 避开 ~960 个特权端口的耗尽问题,要求 server 端启用 insecure(NFSv3 export 选项)。
140    if noresvport {
141        let socket = if addr.is_ipv4() {
142            TcpSocket::new_v4()?
143        } else {
144            TcpSocket::new_v6()?
145        };
146        socket.set_reuseaddr(true)?;
147        socket.bind(local_addr_base)?;
148        let stream = socket.connect(*addr).await?;
149        stream.set_nodelay(true)?;
150        const KEEPALIVE_TIME_SECS: u64 = 30;
151        const KEEPALIVE_INTERVAL_SECS: u64 = 5;
152        #[cfg(target_os = "linux")]
153        const KEEPALIVE_RETRIES: u32 = 3;
154        let sock_ref = socket2::SockRef::from(&stream);
155        let keepalive = socket2::TcpKeepalive::new()
156            .with_time(std::time::Duration::from_secs(KEEPALIVE_TIME_SECS))
157            .with_interval(std::time::Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
158        #[cfg(target_os = "linux")]
159        let keepalive = keepalive.with_retries(KEEPALIVE_RETRIES);
160        sock_ref.set_tcp_keepalive(&keepalive)?;
161        info!(
162            addr = %addr,
163            local_port = stream.local_addr().map(|a| a.port()).unwrap_or(0),
164            "TCP connection established (ephemeral source port, noresvport)"
165        );
166        return Ok(stream);
167    }
168    // 预计算可用端口列表(1-1023 排除已知端口),约 960 个可用
169    let available_ports: Vec<u16> = (1..1024u16)
170        .filter(|p| !WELL_KNOWN_PORTS.contains(p))
171        .collect();
172    // Start at a random point, then visit every candidate exactly once. With
173    // SO_REUSEADDR, bind may succeed for a port already used by another
174    // outbound socket; Linux reports the duplicate four-tuple from connect as
175    // EADDRNOTAVAIL, while Windows may report EADDRINUSE.
176    let start = rand::random_range(0..available_ports.len());
177    let candidates = privileged_port_candidates(&available_ports, start);
178    let mut last_collision = None;
179    for (attempt, local_port) in candidates.iter().copied().enumerate() {
180        let socket = if addr.is_ipv4() {
181            TcpSocket::new_v4()?
182        } else {
183            TcpSocket::new_v6()?
184        };
185        // 允许 bind 到处于 TIME_WAIT 状态的端口,避免重复运行时端口耗尽
186        socket.set_reuseaddr(true)?;
187        let mut local_addr = local_addr_base;
188        local_addr.set_port(local_port);
189        match socket.bind(local_addr) {
190            Ok(_) => {}
191            Err(e) if is_privileged_port_collision(&e) => {
192                trace!(
193                    local_port,
194                    error = %e,
195                    "source port bind collision, trying another"
196                );
197                last_collision = Some(e);
198                continue;
199            }
200            Err(e) => return Err(e.into()),
201        }
202        debug!(local_port = local_addr.port(), addr = %addr, "bound to local port, connecting");
203        match socket.connect(*addr).await {
204            Ok(stream) => {
205                stream.set_nodelay(true)?;
206                const KEEPALIVE_TIME_SECS: u64 = 30;
207                const KEEPALIVE_INTERVAL_SECS: u64 = 5;
208                #[cfg(target_os = "linux")]
209                const KEEPALIVE_RETRIES: u32 = 3;
210
211                let sock_ref = socket2::SockRef::from(&stream);
212                let keepalive = socket2::TcpKeepalive::new()
213                    .with_time(std::time::Duration::from_secs(KEEPALIVE_TIME_SECS))
214                    .with_interval(std::time::Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
215                #[cfg(target_os = "linux")]
216                let keepalive = keepalive.with_retries(KEEPALIVE_RETRIES);
217                sock_ref.set_tcp_keepalive(&keepalive)?;
218                info!(addr = %addr, local_port = local_addr.port(), "TCP connection established");
219                return Ok(stream);
220            }
221            Err(e) if is_privileged_port_collision(&e) => {
222                debug!(
223                    local_port,
224                    addr = %addr,
225                    attempt,
226                    error = %e,
227                    "connect found a privileged-port tuple collision, trying another port"
228                );
229                last_collision = Some(e);
230                continue;
231            }
232            Err(e) => return Err(e.into()),
233        }
234    }
235    let attempted = candidates.len();
236    let last_collision = last_collision.unwrap_or_else(|| {
237        std::io::Error::new(
238            std::io::ErrorKind::AddrNotAvailable,
239            "no privileged source-port candidate was available",
240        )
241    });
242    warn!(addr = %addr, attempted, error = %last_collision, "all privileged source ports failed");
243    Err(NfsError::Io(std::io::Error::new(
244        last_collision.kind(),
245        format!(
246            "failed to connect to {addr} after trying {attempted} privileged source ports; last error: {last_collision}"
247        ),
248    )))
249}
250
251#[cfg(any(test, feature = "python-bindings"))]
252#[cfg_attr(all(feature = "python-bindings", not(test)), allow(dead_code))]
253mod client_core;
254#[cfg(test)]
255mod client_core_contract;
256pub mod error;
257mod fileio;
258mod mount;
259mod nfs3;
260mod nfs4;
261mod nfs40;
262mod nfs41;
263#[cfg(feature = "python-bindings")]
264mod python_adapter;
265mod rpc;
266mod shared;
267
268pub use error::{
269    NfsError, OperationClass, OperationOutcome, OperationOutcomeError, RecoveryAction,
270    RequestContext, RequestId, RequestTransmission, Result,
271};
272pub use fileio::{BufferedFile, write_all};
273pub use mount::{
274    AceFlags, AceMask, AceType, Acl, Acl41Flags, AclSupport, Attr, CallbackStats, ExportEntry,
275    FSInfo, FSStat, LockToken, Mount, MountCapabilities, MountHealth, MountLifecycleState,
276    NFSVersion, Nfs41CallbackStats, Nfs41ChannelLimits, NfsAce, NfsAcl41, OPEN_BOTH, OPEN_READ,
277    OPEN_WRITE, ObjRes, OpenFile, Pathconf, PathconfSupport, ReaddirEntry, ReaddirStream,
278    ReaddirplusEntry, ReaddirplusStream, SupportedPathconf, WriteCommitted, WriteOutcome,
279};
280pub use shared::Time;
281// 公开 NFS 错误码类型,供外部 crate 进行错误匹配
282pub use nfs3::ErrorCode as Nfs3ErrorCode;
283pub use nfs3::MountErrorCode as Nfs3MountErrorCode;
284pub use nfs4::Nfs4ErrorCode;
285// Decoder wrappers exposed solely for `benches/`. Not part of the stable API;
286// `#[doc(hidden)]` keeps it out of rustdoc. Internal XDR types stay private —
287// only `bytes::Bytes` and the public `NfsError` cross the boundary.
288#[doc(hidden)]
289pub mod __bench {
290    use crate::error::{NfsError, Result};
291    use crate::nfs3::fastxdr::{READ3resok, READDIRPLUS3resok, fattr3, post_op_attr};
292    use bytes::Bytes;
293
294    fn map_xdr_err<E: std::fmt::Display>(e: E) -> NfsError {
295        NfsError::Xdr(e.to_string())
296    }
297
298    /// Decode a single `fattr3` (84 wire bytes).
299    pub fn decode_fattr3(mut buf: Bytes) -> Result<()> {
300        fattr3::try_from(&mut buf).map(|_| ()).map_err(map_xdr_err)
301    }
302
303    /// Decode a `post_op_attr` (4-byte discriminant + optional `fattr3`).
304    pub fn decode_post_op_attr(mut buf: Bytes) -> Result<()> {
305        post_op_attr::try_from(&mut buf)
306            .map(|_| ())
307            .map_err(map_xdr_err)
308    }
309
310    /// Decode a `READ3resok` (post_op_attr + count + eof + variable data slice).
311    pub fn decode_read3resok(mut buf: Bytes) -> Result<()> {
312        READ3resok::try_from(&mut buf)
313            .map(|_| ())
314            .map_err(map_xdr_err)
315    }
316
317    /// Decode a `READDIRPLUS3resok` page (variable number of entries).
318    pub fn decode_readdirplus3resok(mut buf: Bytes) -> Result<()> {
319        READDIRPLUS3resok::try_from(&mut buf)
320            .map(|_| ())
321            .map_err(map_xdr_err)
322    }
323}
324
325use rpc::auth::Auth;
326use tracing::{debug, info, trace, warn};
327use url::Url;
328
329#[derive(Debug)]
330struct MountArgs {
331    versions: Vec<NFSVersion>,
332    host: String,
333    dirpath: String,
334    mountport: u16,
335    nfsport: u16,
336    uid: u32,
337    gid: u32,
338    dircount: u32,
339    maxcount: u32,
340    noresvport: bool,
341    retain_delegations: bool,
342}
343
344/// Parses the specified URL and attempts to mount the relevant NFS export
345///
346/// # Example
347///
348/// ```no_run
349/// async fn example() {
350///     let mount = nfs_rs::parse_url_and_mount("nfs://127.0.0.1/some/export?version=4.1,4.0,3").await.unwrap();
351///     if let Some(res) = mount.create_path("nfs-rs.txt", Some(0o664)).await.ok() {
352///         let contents = "hello rust".as_bytes().to_vec();
353///         let contents_len = contents.len();
354///         let num_bytes_written = nfs_rs::write_all(&*mount, res.fh.clone(), 0, contents.clone().into()).await.unwrap_or_default();
355///         assert_eq!(num_bytes_written as usize, contents_len);
356///         let bytes_read = mount.read(res.fh, 0, 16).await.unwrap_or_default();
357///         assert_eq!(&bytes_read, "hello rust".as_bytes());
358///     }
359/// }
360/// ```
361pub async fn parse_url_and_mount(url: &str) -> Result<Box<dyn Mount>> {
362    mount(parse_url(url)?).await
363}
364
365/// Queries the MOUNT service on the given host and returns all exported file systems.
366///
367/// Equivalent to running `showmount -e HOST`. Does not require an existing NFS mount.
368///
369/// `host` may be a bare hostname or IP address (e.g. `"192.168.1.1"`) or a full
370/// `nfs://` URL (query params `uid`, `gid`, and `mountport` are honoured; the path
371/// and `version` are ignored).
372///
373/// # Example
374///
375/// ```no_run
376/// async fn example() {
377///     let exports = nfs_rs::list_exports("192.168.1.1").await.unwrap();
378///     for e in exports {
379///         println!("{} {:?}", e.path, e.groups);
380///     }
381/// }
382/// ```
383pub async fn list_exports(host: &str) -> Result<Vec<ExportEntry>> {
384    let url = if host.starts_with("nfs://") {
385        host.to_string()
386    } else {
387        format!("nfs://{}/.", host)
388    };
389    nfs3::query_exports(&parse_url(&url)?).await
390}
391
392fn get_uid_gid() -> (u32, u32) {
393    #[cfg(not(unix))]
394    let uid_gid = || (65534, 65534);
395    #[cfg(unix)]
396    // SAFETY: getuid() and getgid() are always-safe POSIX calls with no preconditions.
397    let uid_gid = || unsafe { (nix::libc::getuid(), nix::libc::getgid()) };
398    uid_gid()
399}
400
401fn parse_url(url: &str) -> Result<MountArgs> {
402    let mut parsed_url =
403        Url::parse_with_params(url, &[("version", "3"), ("readdir-buffer", "8192,8192")])
404            .map_err(|e| NfsError::InvalidInput(e.to_string()))?;
405    if parsed_url.scheme() != "nfs" {
406        return Err(NfsError::InvalidInput(
407            "specified URL does not have scheme nfs".to_string(),
408        ));
409    }
410    if !parsed_url.has_host() {
411        return Err(NfsError::InvalidInput(
412            "specified URL does not contain a host".to_string(),
413        ));
414    }
415    let addr_port = parsed_url.port();
416    parsed_url
417        .set_port(None)
418        .map_err(|_| NfsError::InvalidInput("cannot clear port on URL".to_string()))?;
419    let version_str = parsed_url
420        .query_pairs()
421        .find(|(name, _)| name == "version")
422        .ok_or_else(|| NfsError::InvalidInput("missing version parameter".to_string()))?
423        .1;
424    let mut versions = Vec::new();
425    for v in version_str.split(',') {
426        let version: NFSVersion = v.into();
427        match version {
428            NFSVersion::Unknown => {
429                return Err(NfsError::InvalidInput(
430                    "specified URL contains bad NFS version".to_string(),
431                ));
432            }
433            _ => versions.push(version),
434        }
435    }
436    if versions.is_empty() {
437        versions.push(NFSVersion::NFSv4p1);
438        versions.push(NFSVersion::NFSv3);
439    }
440    let (uid_def, gid_def) = get_uid_gid();
441    let uid = get_url_query_param(
442        &parsed_url,
443        "uid",
444        uid_def,
445        "specified URL contains bad UID",
446    )?;
447    let gid = get_url_query_param(
448        &parsed_url,
449        "gid",
450        gid_def,
451        "specified URL contains bad GID",
452    )?;
453    let readdir_buffer_str = parsed_url
454        .query_pairs()
455        .find(|(name, _)| name == "readdir-buffer")
456        .ok_or_else(|| NfsError::InvalidInput("missing readdir-buffer parameter".to_string()))?
457        .1;
458    let (dircount, maxcount): (u32, u32) = parse_readdir_buffer_query_param(&readdir_buffer_str)?;
459    let nfsport = get_url_query_param(
460        &parsed_url,
461        "nfsport",
462        addr_port.unwrap_or_default(),
463        "specified URL contains bad NFS port",
464    )?;
465    let mountport = get_url_query_param(
466        &parsed_url,
467        "mountport",
468        Default::default(),
469        "specified URL contains bad mount port",
470    )?;
471    let noresvport = get_url_query_param(
472        &parsed_url,
473        "noresvport",
474        false,
475        "specified URL contains bad noresvport value",
476    )?;
477    let retain_delegations = get_url_query_param(
478        &parsed_url,
479        "retain-delegations",
480        false,
481        "specified URL contains bad retain-delegations value",
482    )?;
483    if parsed_url
484        .query_pairs()
485        .any(|(key, _)| key == "writeback" || key == "commit_threshold")
486    {
487        return Err(NfsError::InvalidInput("writeback and commit_threshold are no longer supported; writes commit before returning".into()));
488    }
489    if parsed_url
490        .query_pairs()
491        .any(|(key, _)| key == "rsize" || key == "wsize")
492    {
493        return Err(NfsError::InvalidInput(
494            "rsize and wsize are negotiated automatically and cannot be configured".into(),
495        ));
496    }
497    if parsed_url.query_pairs().any(|(key, _)| key == "readahead") {
498        return Err(NfsError::InvalidInput(
499            "readahead is no longer supported; reads cover only the requested buffer".into(),
500        ));
501    }
502    let host = parsed_url.host_str().unwrap_or_default().to_string();
503    Ok(MountArgs {
504        versions,
505        host,
506        mountport,
507        nfsport,
508        dirpath: parsed_url.path().to_string(),
509        uid,
510        gid,
511        dircount,
512        maxcount,
513        noresvport,
514        retain_delegations,
515    })
516}
517
518fn get_url_query_param<T: std::str::FromStr>(
519    url: &url::Url,
520    name: &str,
521    def: T,
522    err_msg: &str,
523) -> Result<T> {
524    match url.query_pairs().find(|(n, _)| n == name) {
525        Some((_, val)) => val
526            .parse()
527            .map_err(|_| NfsError::InvalidInput(err_msg.to_string())),
528        None => Ok(def),
529    }
530}
531
532fn parse_readdir_buffer_query_param(param: &str) -> Result<(u32, u32)> {
533    if let Some((dircount_str, maxcount_str)) = param.split_once(',') {
534        let dircount: u32 = dircount_str.parse().map_err(|_| {
535            NfsError::InvalidInput("specified URL contains bad readdir-buffer value".to_string())
536        })?;
537        let maxcount: u32 = maxcount_str.parse().map_err(|_| {
538            NfsError::InvalidInput("specified URL contains bad readdir-buffer value".to_string())
539        })?;
540        Ok((dircount, maxcount))
541    } else {
542        let count: u32 = param.parse().map_err(|_| {
543            NfsError::InvalidInput("specified URL contains bad readdir-buffer value".to_string())
544        })?;
545        Ok((count, count))
546    }
547}
548
549async fn mount(args: MountArgs) -> Result<Box<dyn Mount>> {
550    let mut errs: Vec<NfsError> = Vec::new();
551    for version in &args.versions {
552        info!(version = ?version, host = %args.host, dirpath = %args.dirpath, "attempting NFS mount");
553        let res: Result<Box<dyn Mount>> = match version {
554            NFSVersion::NFSv3 => nfs3::mount(&args).await,
555            NFSVersion::NFSv4p1 => nfs41::mount::mount(&args).await,
556            NFSVersion::NFSv4p0 => nfs40::mount(&args).await,
557            #[allow(deprecated)]
558            NFSVersion::NFSv4 => Err(NfsError::Unsupported(
559                "NFSv4.0 is not supported".to_string(),
560            )),
561            NFSVersion::NFSv4p2 => Err(NfsError::Unsupported(
562                "NFSv4.2 is not supported".to_string(),
563            )),
564            _ => unreachable!(),
565        };
566        match res {
567            Ok(_) => return res,
568            Err(err) => {
569                warn!(version = ?version, error = %err, "mount attempt failed for version");
570                errs.push(err);
571            }
572        }
573    }
574    Err(squash_mount_errors(errs))
575}
576
577/// Extract the inner message from an NfsError without the variant prefix.
578fn nfs_error_msg(err: &NfsError) -> String {
579    match err {
580        NfsError::Io(e) => e.to_string(),
581        NfsError::Nfs3(c) => c.to_string(),
582        NfsError::Nfs4(c) => c.to_string(),
583        NfsError::LockDenied { .. } => err.to_string(),
584        NfsError::Mount(c) => c.to_string(),
585        NfsError::Rpc(s)
586        | NfsError::Xdr(s)
587        | NfsError::Unsupported(s)
588        | NfsError::InvalidInput(s)
589        | NfsError::ClosedResource(s)
590        | NfsError::ModeViolation(s)
591        | NfsError::ClientClosed(s)
592        | NfsError::PositionUncertain(s)
593        | NfsError::LostOpenState(s) => s.clone(),
594        NfsError::FileClose(errors) => errors.first().map_or_else(
595            || "file close completed with errors".to_string(),
596            |failure| format!("file close completed with errors: {}", failure.error),
597        ),
598        NfsError::RdattrError(code) => format!("rdattr_error: nfsstat4 {}", code),
599        NfsError::OperationOutcome(error) => error.to_string(),
600    }
601}
602
603fn squash_mount_errors(errs: Vec<NfsError>) -> NfsError {
604    let mut unsupported_err = "".to_string();
605    let mut errs: Vec<NfsError> = errs
606        .into_iter()
607        .filter_map(|err| {
608            if matches!(&err, NfsError::Unsupported(_)) {
609                let msg = nfs_error_msg(&err);
610                if unsupported_err.is_empty() {
611                    unsupported_err = msg;
612                } else if unsupported_err != msg {
613                    unsupported_err = "NFSv4.0 and NFSv4.2 are not supported".to_string();
614                }
615                None
616            } else {
617                Some(err)
618            }
619        })
620        .collect();
621    if errs.is_empty() {
622        return NfsError::Unsupported(unsupported_err);
623    }
624    if errs.len() == 1 && unsupported_err.is_empty() {
625        return errs.remove(0);
626    }
627    let mut msg = nfs_error_msg(&errs[0]);
628    for err in &errs[1..] {
629        msg = format!("{} - {}", msg, nfs_error_msg(err));
630    }
631    if !unsupported_err.is_empty() {
632        msg = format!("{} - {}", msg, unsupported_err);
633    }
634    NfsError::Rpc(msg)
635}
636
637fn split_path(path: &str) -> Result<(String, String)> {
638    let cleaned = path_clean::clean(format!("/=/{}", path));
639    if !cleaned.starts_with("/=/") {
640        return Err(NfsError::InvalidInput("invalid path specified".to_string()));
641    }
642    if cleaned.eq(std::path::Path::new("/=/")) {
643        return Ok(("/".to_string(), "".to_string()));
644    }
645    let dir = cleaned
646        .parent()
647        .map(|x| {
648            let path_str = x.to_string_lossy();
649            let trimmed = &path_str[2..];
650            #[cfg(windows)]
651            {
652                // Windows platform: replace backslashes with forward slashes
653                trimmed.replace('\\', "/")
654            }
655            #[cfg(not(windows))]
656            {
657                // Non-Windows platform: keep original separators
658                trimmed.to_string()
659            }
660        })
661        .ok_or_else(|| NfsError::InvalidInput("invalid path specified".to_string()))?;
662
663    let name = cleaned
664        .file_name()
665        .unwrap_or_default()
666        .to_string_lossy()
667        .to_string();
668    Ok((dir, name))
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn parse_url_bad_scheme() {
677        for scheme in ["ftp", "scp", "ssh"] {
678            let res = parse_url(&format!("{}://localhost/some/export/path", scheme));
679            assert!(res.is_err());
680            let err = res.unwrap_err();
681            assert!(
682                matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL does not have scheme nfs")
683            );
684        }
685    }
686
687    #[test]
688    fn parse_url_missing_host() {
689        let res = parse_url("nfs:///some/export/path");
690        assert!(res.is_err());
691        let err = res.unwrap_err();
692        assert!(
693            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL does not contain a host")
694        );
695    }
696
697    #[test]
698    fn parse_url_with_bad_version() {
699        let res = parse_url("nfs://127.0.0.1/some/export/path?version=5");
700        assert!(res.is_err());
701        let err = res.unwrap_err();
702        assert!(
703            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad NFS version")
704        );
705    }
706
707    #[test]
708    fn parse_url_requires_exact_nfsv40_minor_version() {
709        let exact = parse_url("nfs://127.0.0.1/export?version=4.0").unwrap();
710        assert_eq!(exact.versions, vec![NFSVersion::NFSv4p0]);
711
712        let ambiguous = parse_url("nfs://127.0.0.1/export?version=4").unwrap_err();
713        assert!(matches!(ambiguous, NfsError::InvalidInput(_)));
714    }
715
716    #[test]
717    fn parse_url_preserves_explicit_version_fallback_order() {
718        let args = parse_url("nfs://127.0.0.1/export?version=4.1,4.0,3").unwrap();
719        assert_eq!(
720            args.versions,
721            vec![NFSVersion::NFSv4p1, NFSVersion::NFSv4p0, NFSVersion::NFSv3]
722        );
723    }
724
725    #[test]
726    fn parse_url_with_bad_uid() {
727        let res = parse_url("nfs://127.0.0.1/some/export/path?uid=nobody");
728        assert!(res.is_err());
729        let err = res.unwrap_err();
730        assert!(
731            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad UID")
732        );
733    }
734
735    #[test]
736    fn parse_url_with_bad_gid() {
737        let res = parse_url("nfs://127.0.0.1/some/export/path?gid=wheel");
738        assert!(res.is_err());
739        let err = res.unwrap_err();
740        assert!(
741            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad GID")
742        );
743    }
744
745    #[test]
746    fn parse_url_with_bad_nfsport() {
747        let res = parse_url("nfs://127.0.0.1/some/export/path?nfsport=default");
748        assert!(res.is_err());
749        let err = res.unwrap_err();
750        assert!(
751            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad NFS port")
752        );
753    }
754
755    #[test]
756    fn parse_url_with_bad_mountport() {
757        let res = parse_url("nfs://127.0.0.1/some/export/path?mountport=nfsport");
758        assert!(res.is_err());
759        let err = res.unwrap_err();
760        assert!(
761            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad mount port")
762        );
763    }
764
765    #[test]
766    fn parse_url_with_bad_readdir_buffer_single_value() {
767        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=unlimited");
768        assert!(res.is_err());
769        let err = res.unwrap_err();
770        assert!(
771            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad readdir-buffer value")
772        );
773    }
774
775    #[test]
776    fn parse_url_with_bad_readdir_buffer_pair_first_value() {
777        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=unlimited,4096");
778        assert!(res.is_err());
779        let err = res.unwrap_err();
780        assert!(
781            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad readdir-buffer value")
782        );
783    }
784
785    #[test]
786    fn parse_url_with_bad_readdir_buffer_pair_second_value() {
787        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=4096,unlimited");
788        assert!(res.is_err());
789        let err = res.unwrap_err();
790        assert!(
791            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad readdir-buffer value")
792        );
793    }
794
795    #[test]
796    fn parse_url_with_bad_readdir_buffer_triple_value() {
797        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=2048,4096,8192");
798        assert!(res.is_err());
799        let err = res.unwrap_err();
800        assert!(
801            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad readdir-buffer value")
802        );
803    }
804
805    #[test]
806    fn parse_url_rejects_bad_rsize() {
807        let error = parse_url("nfs://127.0.0.1/export?rsize=invalid").unwrap_err();
808        assert!(
809            matches!(error, NfsError::InvalidInput(message) if message.contains("negotiated automatically"))
810        );
811    }
812
813    #[test]
814    fn parse_url_rejects_bad_wsize() {
815        let error = parse_url("nfs://127.0.0.1/export?wsize=invalid").unwrap_err();
816        assert!(
817            matches!(error, NfsError::InvalidInput(message) if message.contains("negotiated automatically"))
818        );
819    }
820
821    #[test]
822    fn parse_url_without_uid_and_gid() {
823        let res = parse_url("nfs://127.0.0.1/some/export/path");
824        assert!(res.is_ok(), "err = {}", res.unwrap_err());
825        let args = res.unwrap();
826        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
827        assert_eq!(args.host, "127.0.0.1".to_string());
828        assert_eq!(args.nfsport, 0);
829        assert_eq!(args.mountport, 0);
830        assert_eq!(args.dirpath, "/some/export/path".to_string());
831        assert_eq!((args.uid, args.gid), get_uid_gid());
832        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
833    }
834
835    #[test]
836    fn parse_url_with_uid_and_gid_and_multi_version() {
837        let res = parse_url("nfs://localhost/some/export/path?version=4.1,4.0,3&uid=616&gid=666");
838        assert!(res.is_ok(), "err = {}", res.unwrap_err());
839        let args = res.unwrap();
840        assert_eq!(
841            args.versions,
842            vec![NFSVersion::NFSv4p1, NFSVersion::NFSv4p0, NFSVersion::NFSv3]
843        );
844        assert_eq!(args.host, "localhost".to_string());
845        assert_eq!(args.nfsport, 0);
846        assert_eq!(args.mountport, 0);
847        assert_eq!(args.dirpath, "/some/export/path".to_string());
848        assert_eq!((args.uid, args.gid), (616, 666));
849        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
850    }
851
852    #[test]
853    fn parse_url_with_port() {
854        let res = parse_url("nfs://localhost:20490/some/export/path");
855        assert!(res.is_ok(), "err = {}", res.unwrap_err());
856        let args = res.unwrap();
857        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
858        assert_eq!(args.host, "localhost".to_string());
859        assert_eq!(args.nfsport, 20490);
860        assert_eq!(args.mountport, 0);
861        assert_eq!(args.dirpath, "/some/export/path".to_string());
862        assert_eq!((args.uid, args.gid), get_uid_gid());
863        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
864    }
865
866    #[test]
867    fn parse_url_with_nfsport() {
868        let res = parse_url("nfs://localhost/some/export/path?nfsport=20490");
869        assert!(res.is_ok(), "err = {}", res.unwrap_err());
870        let args = res.unwrap();
871        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
872        assert_eq!(args.host, "localhost".to_string());
873        assert_eq!(args.nfsport, 20490);
874        assert_eq!(args.mountport, 0);
875        assert_eq!(args.dirpath, "/some/export/path".to_string());
876        assert_eq!((args.uid, args.gid), get_uid_gid());
877        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
878    }
879
880    #[test]
881    fn parse_url_with_mountport() {
882        let res = parse_url("nfs://localhost/some/export/path?mountport=20490");
883        assert!(res.is_ok(), "err = {}", res.unwrap_err());
884        let args = res.unwrap();
885        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
886        assert_eq!(args.host, "localhost".to_string());
887        assert_eq!(args.nfsport, 0);
888        assert_eq!(args.mountport, 20490);
889        assert_eq!(args.dirpath, "/some/export/path".to_string());
890        assert_eq!((args.uid, args.gid), get_uid_gid());
891        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
892    }
893
894    #[test]
895    fn parse_url_with_port_and_mountport() {
896        let res = parse_url("nfs://localhost:20389/some/export/path?mountport=20490");
897        assert!(res.is_ok(), "err = {}", res.unwrap_err());
898        let args = res.unwrap();
899        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
900        assert_eq!(args.host, "localhost".to_string());
901        assert_eq!(args.nfsport, 20389);
902        assert_eq!(args.mountport, 20490);
903        assert_eq!(args.dirpath, "/some/export/path".to_string());
904        assert_eq!((args.uid, args.gid), get_uid_gid());
905        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
906    }
907
908    #[test]
909    fn parse_url_with_nfsport_and_mountport() {
910        let res = parse_url("nfs://localhost/some/export/path?nfsport=20389&mountport=20490");
911        assert!(res.is_ok(), "err = {}", res.unwrap_err());
912        let args = res.unwrap();
913        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
914        assert_eq!(args.host, "localhost".to_string());
915        assert_eq!(args.nfsport, 20389);
916        assert_eq!(args.mountport, 20490);
917        assert_eq!(args.dirpath, "/some/export/path".to_string());
918        assert_eq!((args.uid, args.gid), get_uid_gid());
919        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
920    }
921
922    #[test]
923    fn parse_url_with_port_nfsport_and_mountport() {
924        let res = parse_url("nfs://localhost:20388/some/export/path?nfsport=20389&mountport=20490");
925        assert!(res.is_ok(), "err = {}", res.unwrap_err());
926        let args = res.unwrap();
927        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
928        assert_eq!(args.host, "localhost".to_string());
929        assert_eq!(args.nfsport, 20389);
930        assert_eq!(args.mountport, 20490);
931        assert_eq!(args.dirpath, "/some/export/path".to_string());
932        assert_eq!((args.uid, args.gid), get_uid_gid());
933        assert_eq!((args.dircount, args.maxcount), (8192, 8192));
934    }
935
936    #[test]
937    fn parse_url_rejects_rsize() {
938        let error = parse_url("nfs://127.0.0.1/export?rsize=16384").unwrap_err();
939        assert!(
940            matches!(error, NfsError::InvalidInput(message) if message.contains("negotiated automatically"))
941        );
942    }
943
944    #[test]
945    fn parse_url_rejects_wsize() {
946        let error = parse_url("nfs://127.0.0.1/export?wsize=16384").unwrap_err();
947        assert!(
948            matches!(error, NfsError::InvalidInput(message) if message.contains("negotiated automatically"))
949        );
950    }
951
952    #[test]
953    fn parse_url_with_readdir_buffer_single_value() {
954        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=4096");
955        assert!(res.is_ok(), "err = {}", res.unwrap_err());
956        let args = res.unwrap();
957        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
958        assert_eq!(args.host, "127.0.0.1".to_string());
959        assert_eq!(args.nfsport, 0);
960        assert_eq!(args.mountport, 0);
961        assert_eq!(args.dirpath, "/some/export/path".to_string());
962        assert_eq!((args.uid, args.gid), get_uid_gid());
963        assert_eq!((args.dircount, args.maxcount), (4096, 4096));
964    }
965
966    #[test]
967    fn parse_url_with_readdir_buffer_pair_value() {
968        let res = parse_url("nfs://127.0.0.1/some/export/path?readdir-buffer=2048,4096");
969        assert!(res.is_ok(), "err = {}", res.unwrap_err());
970        let args = res.unwrap();
971        assert_eq!(args.versions, vec![NFSVersion::NFSv3]);
972        assert_eq!(args.host, "127.0.0.1".to_string());
973        assert_eq!(args.nfsport, 0);
974        assert_eq!(args.mountport, 0);
975        assert_eq!(args.dirpath, "/some/export/path".to_string());
976        assert_eq!((args.uid, args.gid), get_uid_gid());
977        assert_eq!((args.dircount, args.maxcount), (2048, 4096));
978    }
979
980    #[tokio::test]
981    async fn mount_with_only_v4_0_attempts_the_protocol_engine() {
982        let args = MountArgs {
983            versions: vec![NFSVersion::NFSv4p0],
984            host: Default::default(),
985            mountport: Default::default(),
986            nfsport: Default::default(),
987            dirpath: Default::default(),
988            gid: Default::default(),
989            uid: Default::default(),
990            dircount: Default::default(),
991            maxcount: Default::default(),
992            noresvport: Default::default(),
993            retain_delegations: Default::default(),
994        };
995        let res = mount(args).await;
996        assert!(res.is_err());
997        let err = res.unwrap_err();
998        assert!(!matches!(&err, NfsError::Unsupported(_)));
999    }
1000
1001    #[tokio::test]
1002    async fn mount_with_only_v4_2() {
1003        let args = MountArgs {
1004            versions: vec![NFSVersion::NFSv4p2],
1005            host: Default::default(),
1006            mountport: Default::default(),
1007            nfsport: Default::default(),
1008            dirpath: Default::default(),
1009            gid: Default::default(),
1010            uid: Default::default(),
1011            dircount: Default::default(),
1012            maxcount: Default::default(),
1013            noresvport: Default::default(),
1014            retain_delegations: Default::default(),
1015        };
1016        let res = mount(args).await;
1017        assert!(res.is_err());
1018        let err = res.unwrap_err();
1019        assert!(matches!(&err, NfsError::Unsupported(msg) if msg == "NFSv4.2 is not supported"));
1020    }
1021
1022    #[tokio::test]
1023    async fn mount_with_v4_0_then_v4_2_attempts_v4_0_before_unsupported_fallback() {
1024        let args = MountArgs {
1025            versions: vec![NFSVersion::NFSv4p0, NFSVersion::NFSv4p2],
1026            host: Default::default(),
1027            mountport: Default::default(),
1028            nfsport: Default::default(),
1029            dirpath: Default::default(),
1030            gid: Default::default(),
1031            uid: Default::default(),
1032            dircount: Default::default(),
1033            maxcount: Default::default(),
1034            noresvport: Default::default(),
1035            retain_delegations: Default::default(),
1036        };
1037        let res = mount(args).await;
1038        assert!(res.is_err());
1039        let err = res.unwrap_err();
1040        assert!(matches!(&err, NfsError::Rpc(msg) if msg.contains("NFSv4.2 is not supported")));
1041    }
1042
1043    #[test]
1044    fn squash_mount_errors_with_only_non_unsupported_err() {
1045        let errs = vec![NfsError::Rpc("some error".to_string())];
1046        let err = squash_mount_errors(errs);
1047        assert!(matches!(&err, NfsError::Rpc(msg) if msg == "some error"));
1048    }
1049
1050    #[test]
1051    fn squash_mount_errors_with_only_non_unsupported_errs() {
1052        let errs = vec![
1053            NfsError::Rpc("some error".to_string()),
1054            NfsError::InvalidInput("some other error".to_string()),
1055            NfsError::InvalidInput("some final error".to_string()),
1056        ];
1057        let err = squash_mount_errors(errs);
1058        assert!(
1059            matches!(&err, NfsError::Rpc(msg) if msg == "some error - some other error - some final error")
1060        );
1061    }
1062
1063    #[test]
1064    fn squash_mount_errors_with_only_unsupported_err() {
1065        let errs = vec![
1066            NfsError::Unsupported("NFSv4.2 is not supported".to_string()),
1067            NfsError::Unsupported("NFSv4.2 is not supported".to_string()), // XXX: same NFS version ignored
1068        ];
1069        let err = squash_mount_errors(errs);
1070        assert!(matches!(&err, NfsError::Unsupported(msg) if msg == "NFSv4.2 is not supported"));
1071    }
1072
1073    #[test]
1074    fn squash_mount_errors_with_only_unsupported_errs() {
1075        let errs = vec![
1076            NfsError::Unsupported("NFSv4.2 is not supported".to_string()),
1077            NfsError::Unsupported("NFSv4 is not supported".to_string()),
1078        ];
1079        let err = squash_mount_errors(errs);
1080        assert!(
1081            matches!(&err, NfsError::Unsupported(msg) if msg == "NFSv4.0 and NFSv4.2 are not supported")
1082        );
1083    }
1084
1085    #[test]
1086    fn squash_mount_errors_with_unsupported_err_and_non_unsupported_err() {
1087        let errs = vec![
1088            NfsError::Unsupported("NFSv4.2 is not supported".to_string()),
1089            NfsError::Rpc("some error".to_string()),
1090        ];
1091        let err = squash_mount_errors(errs);
1092        assert!(
1093            matches!(&err, NfsError::Rpc(msg) if msg == "some error - NFSv4.2 is not supported")
1094        );
1095    }
1096
1097    #[test]
1098    fn squash_mount_errors_with_unsupported_errs_and_non_unsupported_err() {
1099        let errs = vec![
1100            NfsError::Unsupported("NFSv4.2 is not supported".to_string()),
1101            NfsError::Rpc("some error".to_string()),
1102            NfsError::Unsupported("NFSv4 is not supported".to_string()),
1103        ];
1104        let err = squash_mount_errors(errs);
1105        assert!(
1106            matches!(&err, NfsError::Rpc(msg) if msg == "some error - NFSv4.0 and NFSv4.2 are not supported")
1107        );
1108    }
1109
1110    #[test]
1111    fn squash_mount_errors_with_unsupported_err_and_non_unsupported_errs() {
1112        let errs = vec![
1113            NfsError::Rpc("some error".to_string()),
1114            NfsError::Unsupported("NFSv4 is not supported".to_string()),
1115            NfsError::InvalidInput("some other error".to_string()),
1116        ];
1117        let err = squash_mount_errors(errs);
1118        assert!(
1119            matches!(&err, NfsError::Rpc(msg) if msg == "some error - some other error - NFSv4 is not supported")
1120        );
1121    }
1122
1123    #[test]
1124    fn squash_mount_errors_with_unsupported_errs_and_non_unsupported_errs() {
1125        let errs = vec![
1126            NfsError::Rpc("some error".to_string()),
1127            NfsError::Unsupported("NFSv4 is not supported".to_string()),
1128            NfsError::InvalidInput("some other error".to_string()),
1129            NfsError::Unsupported("NFSv4.2 is not supported".to_string()),
1130        ];
1131        let err = squash_mount_errors(errs);
1132        assert!(
1133            matches!(&err, NfsError::Rpc(msg) if msg == "some error - some other error - NFSv4.0 and NFSv4.2 are not supported")
1134        );
1135    }
1136
1137    #[test]
1138    fn split_path_empty_path() {
1139        let path = "";
1140        let res = split_path(path);
1141        assert!(res.is_ok());
1142        let (dir, name) = res.unwrap();
1143        assert_eq!(dir, "/".to_string());
1144        assert_eq!(name, "".to_string());
1145    }
1146
1147    #[test]
1148    fn split_path_root_path() {
1149        let path = "/";
1150        let res = split_path(path);
1151        assert!(res.is_ok());
1152        let (dir, name) = res.unwrap();
1153        assert_eq!(dir, "/".to_string());
1154        assert_eq!(name, "".to_string());
1155    }
1156
1157    #[test]
1158    fn split_path_sneaky_one() {
1159        let path = "..";
1160        let res = split_path(path);
1161        assert!(res.is_err());
1162        let err = res.unwrap_err();
1163        assert!(matches!(&err, NfsError::InvalidInput(msg) if msg == "invalid path specified"));
1164    }
1165
1166    #[test]
1167    fn split_path_sneaky_two() {
1168        let path = "/first/../..";
1169        let res = split_path(path);
1170        assert!(res.is_err());
1171        let err = res.unwrap_err();
1172        assert!(matches!(&err, NfsError::InvalidInput(msg) if msg == "invalid path specified"));
1173    }
1174
1175    #[test]
1176    fn split_path_path_depth_one() {
1177        let path = "/first/place/";
1178        let res = split_path(path);
1179        assert!(res.is_ok());
1180        let (dir, name) = res.unwrap();
1181        assert_eq!(dir, "/first".to_string());
1182        assert_eq!(name, "place".to_string());
1183    }
1184
1185    #[test]
1186    fn split_path_path_depth_two() {
1187        let path = "/first/place/1999.txt";
1188        let res = split_path(path);
1189        assert!(res.is_ok());
1190        let (dir, name) = res.unwrap();
1191        assert_eq!(dir, "/first/place".to_string());
1192        assert_eq!(name, "1999.txt".to_string());
1193    }
1194
1195    #[test]
1196    fn parse_url_noresvport_true() {
1197        let args = parse_url("nfs://127.0.0.1/some/export?noresvport=true").unwrap();
1198        assert!(args.noresvport, "noresvport=true should parse to true");
1199    }
1200
1201    #[test]
1202    fn parse_url_noresvport_default_false() {
1203        let args = parse_url("nfs://127.0.0.1/some/export").unwrap();
1204        assert!(
1205            !args.noresvport,
1206            "default should be false (preserve legacy privileged-port behavior)"
1207        );
1208    }
1209
1210    #[test]
1211    fn parse_url_rejects_removed_io_options() {
1212        for option in [
1213            "readahead=0",
1214            "readahead=8",
1215            "writeback=0",
1216            "commit_threshold=16",
1217        ] {
1218            assert!(parse_url(&format!("nfs://127.0.0.1/export?{option}")).is_err());
1219        }
1220    }
1221
1222    #[test]
1223    fn parse_url_retain_delegations_is_explicit_and_default_off() {
1224        let default = parse_url("nfs://127.0.0.1/export?version=4.1").unwrap();
1225        let enabled =
1226            parse_url("nfs://127.0.0.1/export?version=4.1&retain-delegations=true").unwrap();
1227        assert!(!default.retain_delegations);
1228        assert!(enabled.retain_delegations);
1229        assert!(parse_url("nfs://127.0.0.1/export?version=4.1&retain-delegations=maybe").is_err());
1230    }
1231
1232    #[test]
1233    fn parse_url_noresvport_explicit_false() {
1234        let args = parse_url("nfs://127.0.0.1/some/export?noresvport=false").unwrap();
1235        assert!(!args.noresvport, "noresvport=false should parse to false");
1236    }
1237
1238    #[test]
1239    fn parse_url_with_bad_noresvport() {
1240        let res = parse_url("nfs://127.0.0.1/some/export?noresvport=yes");
1241        assert!(res.is_err());
1242        let err = res.unwrap_err();
1243        assert!(
1244            matches!(&err, NfsError::InvalidInput(msg) if msg == "specified URL contains bad noresvport value")
1245        );
1246    }
1247
1248    #[tokio::test]
1249    async fn connect_to_target_ephemeral_when_noresvport_true() {
1250        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1251        let listen_addr = listener.local_addr().unwrap();
1252        let accept_handle = tokio::spawn(async move {
1253            let (stream, peer) = listener.accept().await.unwrap();
1254            (stream, peer)
1255        });
1256        let stream = connect_to_target(&listen_addr, true).await.unwrap();
1257        let local_port = stream.local_addr().unwrap().port();
1258        assert!(
1259            local_port >= 1024,
1260            "with noresvport=true, source port {} must be ephemeral (>=1024)",
1261            local_port
1262        );
1263        let _ = accept_handle.await.unwrap();
1264    }
1265
1266    #[tokio::test]
1267    async fn connect_to_target_privileged_when_noresvport_false() {
1268        // 探测:本机是否允许绑特权端口(Unix root / Windows admin)。
1269        // 没有权限时直接跳过,避免在无特权 CI 上误报 false negative。
1270        let probe = match tokio::net::TcpSocket::new_v4() {
1271            Ok(s) => s.bind("127.0.0.1:1".parse().unwrap()).is_ok(),
1272            _ => false,
1273        };
1274        if !probe {
1275            eprintln!("skipping: insufficient privilege to bind <1024");
1276            return;
1277        }
1278        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1279        let listen_addr = listener.local_addr().unwrap();
1280        let accept_handle = tokio::spawn(async move { listener.accept().await.unwrap() });
1281        let stream = connect_to_target(&listen_addr, false).await.unwrap();
1282        let local_port = stream.local_addr().unwrap().port();
1283        assert!(
1284            (1..1024).contains(&local_port),
1285            "with noresvport=false, source port {} must be privileged (1-1023)",
1286            local_port
1287        );
1288        let _ = accept_handle.await.unwrap();
1289    }
1290
1291    #[test]
1292    fn privileged_port_candidates_visit_every_port_once_from_random_start() {
1293        let ports = vec![2, 3, 4, 5];
1294        assert_eq!(privileged_port_candidates(&ports, 2), vec![4, 5, 2, 3]);
1295    }
1296
1297    #[test]
1298    fn bind_and_connect_tuple_collisions_try_another_privileged_port() {
1299        for kind in [
1300            std::io::ErrorKind::AddrInUse,
1301            std::io::ErrorKind::AddrNotAvailable,
1302        ] {
1303            assert!(is_privileged_port_collision(&std::io::Error::from(kind)));
1304        }
1305        assert!(!is_privileged_port_collision(&std::io::Error::from(
1306            std::io::ErrorKind::ConnectionRefused,
1307        )));
1308    }
1309
1310    #[tokio::test]
1311    async fn nfs3_mount_preserves_the_last_address_error() {
1312        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1313        let port = listener.local_addr().unwrap().port();
1314        drop(listener);
1315
1316        let error = parse_url_and_mount(&format!(
1317            "nfs://127.0.0.1/export?version=3&nfsport={port}&mountport={port}&noresvport=true"
1318        ))
1319        .await
1320        .unwrap_err();
1321
1322        assert!(
1323            matches!(error, NfsError::Io(ref error) if error.kind() == std::io::ErrorKind::ConnectionRefused),
1324            "last endpoint error was hidden: {error:?}"
1325        );
1326    }
1327}