use std::{
ffi::OsStr,
path::{Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use async_trait::async_trait;
use nfsserve::{
nfs::{
fattr3, fileid3, filename3, ftype3, mode3, nfspath3, nfsstat3, nfstime3, sattr3, specdata3,
},
tcp::{NFSTcp, NFSTcpListener},
vfs::{DirEntry, NFSFileSystem, ReadDirResult, VFSCapabilities},
};
use tokio::runtime::{Builder, Runtime};
use tracing::{debug, warn};
use crate::{
core::ContentAddressedMount,
error::{MountError, Result},
shell::{NodeId, NodeKind, PlatformShell},
};
pub struct NfsShell {
inner: Arc<dyn PlatformShell + Send + Sync>,
}
impl NfsShell {
pub fn new(mount: ContentAddressedMount) -> Self {
Self::from_shell(Arc::new(mount))
}
pub fn from_shell(shell: Arc<dyn PlatformShell + Send + Sync>) -> Self {
Self { inner: shell }
}
pub fn is_runtime_available() -> bool {
true
}
pub fn mount_background(self, mountpoint: impl AsRef<Path>) -> Result<NfsSession> {
let mountpoint = mountpoint.as_ref().to_path_buf();
std::fs::create_dir_all(&mountpoint)
.map_err(|e| MountError::Store(objects::error::HeddleError::Io(e)))?;
let runtime = Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("heddle-nfs")
.build()
.map_err(|e| MountError::Store(objects::error::HeddleError::Io(e)))?;
let fs = HeddleNFS {
inner: Arc::clone(&self.inner),
};
let listener = match bind_nfs_listener(&runtime, fs) {
Ok(listener) => listener,
Err(error) => {
runtime.shutdown_background();
return Err(error);
}
};
let port = listener.get_listen_port();
debug!(port, "heddle nfs server listening");
runtime.spawn(async move {
if let Err(e) = listener.handle_forever().await {
warn!("nfs server exited: {e}");
}
});
if let Err(e) = invoke_mount(&mountpoint, port) {
runtime.shutdown_background();
return Err(MountError::Store(objects::error::HeddleError::Io(e)));
}
Ok(NfsSession {
runtime: Some(runtime),
mountpoint,
port,
unmounted: false,
})
}
}
fn bind_nfs_listener(runtime: &Runtime, fs: HeddleNFS) -> Result<NFSTcpListener<HeddleNFS>> {
let handle = runtime.handle().clone();
let join = std::thread::Builder::new()
.name("heddle-nfs-bind".to_string())
.spawn(move || handle.block_on(NFSTcpListener::bind("127.0.0.1:0", fs)))
.map_err(|e| MountError::Store(objects::error::HeddleError::Io(e)))?;
join.join()
.map_err(|_| {
MountError::Store(objects::error::HeddleError::Io(std::io::Error::other(
"nfs bind thread panicked",
)))
})?
.map_err(|e| MountError::Store(objects::error::HeddleError::Io(e)))
}
pub struct NfsSession {
runtime: Option<Runtime>,
mountpoint: PathBuf,
#[allow(dead_code)]
port: u16,
unmounted: bool,
}
impl NfsSession {
pub fn unmount(mut self) -> Result<()> {
invoke_unmount(&self.mountpoint)
.map_err(|e| MountError::Store(objects::error::HeddleError::Io(e)))?;
self.unmounted = true;
if let Some(rt) = self.runtime.take() {
rt.shutdown_background();
}
Ok(())
}
pub fn mountpoint(&self) -> &Path {
&self.mountpoint
}
}
impl Drop for NfsSession {
fn drop(&mut self) {
if !self.unmounted
&& let Err(e) = invoke_unmount(&self.mountpoint)
{
warn!(
mountpoint = %self.mountpoint.display(),
"nfs unmount on drop failed: {e}",
);
}
if let Some(rt) = self.runtime.take() {
rt.shutdown_background();
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn invoke_mount(mountpoint: &Path, port: u16) -> std::io::Result<()> {
use std::process::Command;
let opts =
format!("vers=3,tcp,port={port},mountport={port},nolocks,soft,intr,actimeo=0,resvport=off");
let status = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg("127.0.0.1:/")
.arg(mountpoint)
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"mount(8) returned {status} (NFS mount usually requires sudo)"
)));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn invoke_mount(mountpoint: &Path, port: u16) -> std::io::Result<()> {
use std::process::Command;
let mp_str = mountpoint
.to_str()
.ok_or_else(|| std::io::Error::other("non-UTF8 mountpoint"))?;
let status = Command::new("mount.exe")
.arg("-o")
.arg(format!("anon,nolock,mtype=hard,port={port}"))
.arg("127.0.0.1:/")
.arg(mp_str)
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"mount.exe returned {status} (NFS mount on Windows needs the \
'Services for NFS — Client for NFS' optional feature and an \
elevated console)"
)));
}
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn invoke_mount(_mountpoint: &Path, _port: u16) -> std::io::Result<()> {
Err(std::io::Error::other(
"NFS fallback is only supported on Linux, macOS, and Windows",
))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn invoke_unmount(mountpoint: &Path) -> std::io::Result<()> {
use std::process::Command;
let status = Command::new("umount").arg(mountpoint).status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"umount(8) returned {status}"
)));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn invoke_unmount(mountpoint: &Path) -> std::io::Result<()> {
use std::process::Command;
let mp_str = mountpoint
.to_str()
.ok_or_else(|| std::io::Error::other("non-UTF8 mountpoint"))?;
let status = Command::new("umount.exe").arg("-f").arg(mp_str).status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"umount.exe returned {status}"
)));
}
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn invoke_unmount(_mountpoint: &Path) -> std::io::Result<()> {
Ok(())
}
struct HeddleNFS {
inner: Arc<dyn PlatformShell + Send + Sync>,
}
#[async_trait]
impl NFSFileSystem for HeddleNFS {
fn capabilities(&self) -> VFSCapabilities {
VFSCapabilities::ReadWrite
}
fn root_dir(&self) -> fileid3 {
NodeId::ROOT.0
}
async fn lookup(
&self,
dirid: fileid3,
filename: &filename3,
) -> std::result::Result<fileid3, nfsstat3> {
let name = OsStr::new(
std::str::from_utf8(filename.as_ref()).map_err(|_| nfsstat3::NFS3ERR_INVAL)?,
);
match self.inner.lookup(NodeId(dirid), name) {
Ok(Some(entry)) => Ok(entry.node.0),
Ok(None) => Err(nfsstat3::NFS3ERR_NOENT),
Err(e) => Err(mount_err_to_nfs(&e)),
}
}
async fn getattr(&self, id: fileid3) -> std::result::Result<fattr3, nfsstat3> {
let attrs = self
.inner
.attrs(NodeId(id))
.map_err(|e| mount_err_to_nfs(&e))?;
Ok(fattr_from(
id,
attrs.kind,
attrs.size,
attrs.unix_mode,
attrs.nlink,
attrs.mtime,
))
}
async fn setattr(&self, id: fileid3, setattr: sattr3) -> std::result::Result<fattr3, nfsstat3> {
if let nfsserve::nfs::set_size3::size(requested) = setattr.size {
let current = self
.inner
.attrs(NodeId(id))
.map_err(|e| mount_err_to_nfs(&e))?
.size;
if requested != current {
tracing::warn!(
node = id,
requested,
current,
"nfs: rejecting setattr size change — truncation not yet supported in shell"
);
return Err(nfsstat3::NFS3ERR_NOTSUPP);
}
}
self.getattr(id).await
}
async fn read(
&self,
id: fileid3,
offset: u64,
count: u32,
) -> std::result::Result<(Vec<u8>, bool), nfsstat3> {
let attrs = self
.inner
.attrs(NodeId(id))
.map_err(|e| mount_err_to_nfs(&e))?;
let size = attrs.size;
let end = offset.saturating_add(count as u64).min(size);
let want = end.saturating_sub(offset);
let mut buf = vec![0u8; want as usize];
if want > 0 {
let n = self
.inner
.read(NodeId(id), offset, &mut buf)
.map_err(|e| mount_err_to_nfs(&e))?;
buf.truncate(n);
}
let eof = end >= size;
Ok((buf, eof))
}
async fn write(
&self,
id: fileid3,
offset: u64,
data: &[u8],
) -> std::result::Result<fattr3, nfsstat3> {
self.inner
.write(NodeId(id), offset, data)
.map_err(|e| mount_err_to_nfs(&e))?;
self.getattr(id).await
}
async fn create(
&self,
_dirid: fileid3,
_filename: &filename3,
_attr: sattr3,
) -> std::result::Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn create_exclusive(
&self,
_dirid: fileid3,
_filename: &filename3,
) -> std::result::Result<fileid3, nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn mkdir(
&self,
_dirid: fileid3,
_dirname: &filename3,
) -> std::result::Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn remove(
&self,
_dirid: fileid3,
_filename: &filename3,
) -> std::result::Result<(), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn rename(
&self,
_from_dirid: fileid3,
_from_filename: &filename3,
_to_dirid: fileid3,
_to_filename: &filename3,
) -> std::result::Result<(), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn readdir(
&self,
dirid: fileid3,
start_after: fileid3,
max_entries: usize,
) -> std::result::Result<ReadDirResult, nfsstat3> {
let entries = self
.inner
.enumerate(NodeId(dirid))
.map_err(|e| mount_err_to_nfs(&e))?;
let mut produced: Vec<DirEntry> = Vec::new();
let mut started = start_after == 0;
for entry in entries.iter() {
if !started {
if entry.node.0 == start_after {
started = true;
}
continue;
}
if produced.len() >= max_entries {
break;
}
let attrs = self
.inner
.attrs(entry.node)
.map_err(|e| mount_err_to_nfs(&e))?;
produced.push(DirEntry {
fileid: entry.node.0,
name: filename3::from(entry.name.as_encoded_bytes().to_vec()),
attr: fattr_from(
entry.node.0,
entry.kind,
entry.size,
entry.unix_mode,
attrs.nlink,
attrs.mtime,
),
});
}
let end = produced.len() < max_entries
|| (start_after == 0 && produced.len() == entries.len())
|| produced
.last()
.map(|last| {
entries
.last()
.map(|e| e.node.0 == last.fileid)
.unwrap_or(false)
})
.unwrap_or(false);
Ok(ReadDirResult {
entries: produced,
end,
})
}
async fn symlink(
&self,
_dirid: fileid3,
_linkname: &filename3,
_symlink: &nfspath3,
_attr: &sattr3,
) -> std::result::Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn readlink(&self, id: fileid3) -> std::result::Result<nfspath3, nfsstat3> {
let attrs = self
.inner
.attrs(NodeId(id))
.map_err(|e| mount_err_to_nfs(&e))?;
if !matches!(attrs.kind, NodeKind::Symlink) {
return Err(nfsstat3::NFS3ERR_INVAL);
}
const MAX_SYMLINK_BYTES: u64 = 4096;
if attrs.size > MAX_SYMLINK_BYTES {
tracing::warn!(
node = id,
size = attrs.size,
"nfs: symlink target exceeds PATH_MAX-class bound"
);
return Err(nfsstat3::NFS3ERR_NAMETOOLONG);
}
let mut buf = vec![0u8; attrs.size as usize];
let n = self
.inner
.read(NodeId(id), 0, &mut buf)
.map_err(|e| mount_err_to_nfs(&e))?;
buf.truncate(n);
Ok(nfspath3 { 0: buf })
}
}
fn fattr_from(
fileid: fileid3,
kind: NodeKind,
size: u64,
unix_mode: u32,
nlink: u32,
mtime: SystemTime,
) -> fattr3 {
let ftype = match kind {
NodeKind::Directory => ftype3::NF3DIR,
NodeKind::File => ftype3::NF3REG,
NodeKind::Symlink => ftype3::NF3LNK,
};
let nfstime = system_time_to_nfstime(mtime);
fattr3 {
ftype,
mode: (unix_mode & 0o7777) as mode3,
nlink,
uid: 0,
gid: 0,
size,
used: size,
rdev: specdata3::default(),
fsid: 0,
fileid,
atime: nfstime,
mtime: nfstime,
ctime: nfstime,
}
}
fn system_time_to_nfstime(t: SystemTime) -> nfstime3 {
match t.duration_since(UNIX_EPOCH) {
Ok(d) => nfstime3 {
seconds: d.as_secs() as u32,
nseconds: d.subsec_nanos(),
},
Err(_) => nfstime3 {
seconds: 0,
nseconds: 0,
},
}
}
fn mount_err_to_nfs(err: &MountError) -> nfsstat3 {
match err {
MountError::NotFound(_) | MountError::UnknownThread(_) => nfsstat3::NFS3ERR_NOENT,
MountError::Stale(_) => nfsstat3::NFS3ERR_STALE,
MountError::NotADirectory(_) => nfsstat3::NFS3ERR_NOTDIR,
MountError::ReadOnly => nfsstat3::NFS3ERR_ROFS,
MountError::AlreadyExists(_) => nfsstat3::NFS3ERR_EXIST,
MountError::IsADirectory(_) => nfsstat3::NFS3ERR_ISDIR,
MountError::NotEmpty(_) => nfsstat3::NFS3ERR_NOTEMPTY,
MountError::InvalidArgument(_) => nfsstat3::NFS3ERR_INVAL,
MountError::FileTooLarge(_) => nfsstat3::NFS3ERR_FBIG,
MountError::SessionInit(_) => nfsstat3::NFS3ERR_IO,
MountError::Store(_) => nfsstat3::NFS3ERR_IO,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_runtime_available_is_always_true() {
assert!(NfsShell::is_runtime_available());
}
#[test]
fn mount_err_to_nfs_maps_known_variants() {
assert!(matches!(
mount_err_to_nfs(&MountError::NotFound("x".into())),
nfsstat3::NFS3ERR_NOENT
));
assert!(matches!(
mount_err_to_nfs(&MountError::ReadOnly),
nfsstat3::NFS3ERR_ROFS
));
assert!(matches!(
mount_err_to_nfs(&MountError::NotADirectory("d".into())),
nfsstat3::NFS3ERR_NOTDIR
));
}
}