1use 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 const WELL_KNOWN_PORTS: &[u16] = &[
72 1, 7, 9, 11, 13, 15, 20, 21, 22, 23, 25, 37, 42, 43, 49, 53, 67, 68, 69, 70, 79, 80, 88, 102, 110, 111, 119, 123, 135, 137, 138, 139, 143, 161, 162, 179, 389, 427, 443, 445, 464, 465, 514, 515, 520, 530, 543, 544, 546, 547, 548, 554, 587, 593, 631, 636, 873, 990, 993, 995, ];
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 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 let available_ports: Vec<u16> = (1..1024u16)
170 .filter(|p| !WELL_KNOWN_PORTS.contains(p))
171 .collect();
172 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 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;
281pub use nfs3::ErrorCode as Nfs3ErrorCode;
283pub use nfs3::MountErrorCode as Nfs3MountErrorCode;
284pub use nfs4::Nfs4ErrorCode;
285#[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 pub fn decode_fattr3(mut buf: Bytes) -> Result<()> {
300 fattr3::try_from(&mut buf).map(|_| ()).map_err(map_xdr_err)
301 }
302
303 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 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 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
344pub async fn parse_url_and_mount(url: &str) -> Result<Box<dyn Mount>> {
362 mount(parse_url(url)?).await
363}
364
365pub 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 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
577fn 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 trimmed.replace('\\', "/")
654 }
655 #[cfg(not(windows))]
656 {
657 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()), ];
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 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}