Behaviour for the filesystem layer the NFSv3 handler delegates to.
Keeps Tahr decoupled from any particular backing store — the same handler can be wired to an in-memory test fixture, a posix filesystem mount, a clustered storage layer, etc. The procedure handler itself is purely XDR-decode → callback → XDR-encode; every semantic decision lives in the backend.
Callback shape
Every callback returns {:ok, ...} | {:error, nfsstat3()}. The
handler maps the status atom to the wire integer. Where the RFC
expects a post_op_attr even on failure (most procedures), the
handler attaches nil if the backend doesn't supply one, or the
backend can return {:error, nfsstat3, post_op_attr} for a richer
error reply (see individual callbacks).
ctx
The ctx map is the RPC context the dispatcher hands to the
handler — %{call: Tahr.RPC.Message.Call.t()} plus any
keys downstream code attaches (auth credential, peer address,
etc.). Treat it as opaque; pull what you need by key.
Summary
Types
Opaque context — the handler passes the RPC ctx through.
RFC 1813 §3.3.19 FSINFO reply body. properties is an OR of the
FSF3 flag constants (see Tahr.NFSv3.Types-adjacent
documentation in the handler).
RFC 1813 §3.3.18 FSSTAT reply body (everything except the leading
post_op_attr).
RFC 1813 §3.3.20 PATHCONF reply body.
Read-back from read/5 — a chunk-iterator (any Enumerable.t()
whose elements are binaries), the EOF flag, and a post-op attr
for the file. The iterator must not materialise the whole file
in memory — the handler streams chunks straight to the socket.
An entry returned by readdir/6 — {fileid, name, cookie}.
Cookies are opaque to the handler; the backend chooses how they
encode position.
An entry returned by readdirplus/7 — readdir_entry plus a
post_op_attr and post_op_fh3 per RFC 1813 §3.3.17. Both
optional fields may be nil.
Callbacks
ACCESS — RFC 1813 §3.3.4. granted_mask is the subset of
requested_mask (the same ACCESS3_* bit flags) the server is
willing to permit. Even on success the server includes a
post_op_attr so the client can refresh its cache.
COMMIT — RFC 1813 §3.3.21. Flush any unstable writes covering
[offset, offset+count) to stable storage. Returns the
parent file's wcc_data plus the per-instance writeverf3 —
clients use the verf to detect a server restart and resend lost
unstable writes.
CREATE — RFC 1813 §3.3.8. Create a regular file under dir_fh / name. The mode discriminated union (createhow3) selects
FSINFO — RFC 1813 §3.3.19.
FSSTAT — RFC 1813 §3.3.18.
GETATTR — RFC 1813 §3.3.1.
LINK — RFC 1813 §3.3.15. Create a hard link from
link_dir_fh / name to file_fh. Servers without hard-link
support return NFS3ERR_NOTSUPP per RFC 1813.
LOOKUP — RFC 1813 §3.3.3. Returns the looked-up file's handle, its
optional post_op_attr, and the directory's post_op_attr. On
failure, the directory post_op_attr is still returned where
available.
MKDIR — RFC 1813 §3.3.9. Create a directory at dir_fh / name.
Reply carries the new dir's optional post_op_fh3, optional
post_op_attr, and the parent's wcc_data.
MKNOD — RFC 1813 §3.3.11. Create a special file (block / char
device, FIFO, or socket). RFC 1813 explicitly allows servers
that don't support special files to return NFS3ERR_NOTSUPP;
most non-POSIX-stack servers do exactly that.
PATHCONF — RFC 1813 §3.3.20.
READ — RFC 1813 §3.3.6. The data field of the success reply must
be a chunk-iterator (Enumerable.t() of binary segments) whose
total bytes equal at most count. Returning a flat binary is
equivalent to a one-element iterator but defeats the streaming
invariant the handler relies on.
READDIR — RFC 1813 §3.3.16. The handler decides where to splice in
. / ..; the backend just returns its own entries. Cookies
are opaque — the backend may encode position in any way it
prefers.
READDIRPLUS — RFC 1813 §3.3.17. dircount is the byte budget for
entry names; maxcount is the byte budget for the whole reply
(including post-op attrs and fhandles). The backend should honour
maxcount and stop early if the next entry would push the
budget over.
READLINK — RFC 1813 §3.3.5.
REMOVE — RFC 1813 §3.3.12. Unlink a file by (dir_fh, name).
Reply carries the parent's wcc_data.
RENAME — RFC 1813 §3.3.14. Cross-directory rename. The reply
carries wcc_data for both parent directories (fromdir_wcc
and todir_wcc) so the client can refresh the parent caches on
either side.
RMDIR — RFC 1813 §3.3.13. Remove an empty directory by
(dir_fh, name). Returns NFS3ERR_NOTEMPTY if the directory is
non-empty and NFS3ERR_NOTDIR if the target is not a directory.
Reply carries the parent's wcc_data.
SETATTR — RFC 1813 §3.3.2. Apply the requested sattr3 fields
(any field set to nil means "leave unchanged"; :atime /
:mtime carry RFC 1813's time_set semantics —
:set_to_server_time or {:client, %Nfstime3{}}).
SYMLINK — RFC 1813 §3.3.10. Create a symbolic link at
dir_fh / name pointing at target (an opaque NFS path string —
resolution is the client's responsibility, the server just stores
bytes). Reply carries the new symlink's optional post_op_fh3,
optional post_op_attr, and the parent's wcc_data.
WRITE — RFC 1813 §3.3.7. Write data at offset with the
requested stable hint. The server returns the actual count
written (may be less than the request), the stability level it
achieved (must be >= stable requested), and the per-instance
writeverf3. Backends that always commit synchronously may
return :file_sync regardless of the requested stability — the
client gets stronger guarantees than asked for, which RFC 1813
permits.
Types
@type ctx() :: map()
Opaque context — the handler passes the RPC ctx through.
@type fsinfo_reply() :: %{ rtmax: non_neg_integer(), rtpref: non_neg_integer(), rtmult: non_neg_integer(), wtmax: non_neg_integer(), wtpref: non_neg_integer(), wtmult: non_neg_integer(), dtpref: non_neg_integer(), maxfilesize: non_neg_integer(), time_delta: Tahr.NFSv3.Types.Nfstime3.t(), properties: non_neg_integer() }
RFC 1813 §3.3.19 FSINFO reply body. properties is an OR of the
FSF3 flag constants (see Tahr.NFSv3.Types-adjacent
documentation in the handler).
@type fsstat_reply() :: %{ tbytes: non_neg_integer(), fbytes: non_neg_integer(), abytes: non_neg_integer(), tfiles: non_neg_integer(), ffiles: non_neg_integer(), afiles: non_neg_integer(), invarsec: non_neg_integer() }
RFC 1813 §3.3.18 FSSTAT reply body (everything except the leading
post_op_attr).
@type pathconf_reply() :: %{ linkmax: non_neg_integer(), name_max: non_neg_integer(), no_trunc: boolean(), chown_restricted: boolean(), case_insensitive: boolean(), case_preserving: boolean() }
RFC 1813 §3.3.20 PATHCONF reply body.
@type read_reply() :: %{ :data => Enumerable.t(), :eof => boolean(), optional(:post_op) => Tahr.NFSv3.Types.Fattr3.t() | nil }
Read-back from read/5 — a chunk-iterator (any Enumerable.t()
whose elements are binaries), the EOF flag, and a post-op attr
for the file. The iterator must not materialise the whole file
in memory — the handler streams chunks straight to the socket.
@type readdir_entry() :: {Tahr.NFSv3.Types.fileid3(), Tahr.NFSv3.Types.filename3(), Tahr.NFSv3.Types.nfs_cookie3()}
An entry returned by readdir/6 — {fileid, name, cookie}.
Cookies are opaque to the handler; the backend chooses how they
encode position.
@type readdirplus_entry() :: {Tahr.NFSv3.Types.fileid3(), Tahr.NFSv3.Types.filename3(), Tahr.NFSv3.Types.nfs_cookie3(), Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.fhandle3() | nil}
An entry returned by readdirplus/7 — readdir_entry plus a
post_op_attr and post_op_fh3 per RFC 1813 §3.3.17. Both
optional fields may be nil.
Callbacks
@callback access( Tahr.NFSv3.Types.fhandle3(), requested_mask :: non_neg_integer(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, granted_mask :: non_neg_integer(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
ACCESS — RFC 1813 §3.3.4. granted_mask is the subset of
requested_mask (the same ACCESS3_* bit flags) the server is
willing to permit. Even on success the server includes a
post_op_attr so the client can refresh its cache.
@callback commit( fh :: Tahr.NFSv3.Types.fhandle3(), offset :: Tahr.NFSv3.Types.offset3(), count :: Tahr.NFSv3.Types.count3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, %{wcc: Tahr.NFSv3.Types.WccData.t(), verf: Tahr.NFSv3.Types.writeverf3()}} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
COMMIT — RFC 1813 §3.3.21. Flush any unstable writes covering
[offset, offset+count) to stable storage. Returns the
parent file's wcc_data plus the per-instance writeverf3 —
clients use the verf to detect a server restart and resend lost
unstable writes.
count == 0 means "commit through end-of-file", per RFC 1813.
@callback create( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), mode :: Tahr.NFSv3.Types.createhow3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.fhandle3() | nil, Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
CREATE — RFC 1813 §3.3.8. Create a regular file under dir_fh / name. The mode discriminated union (createhow3) selects:
{:unchecked, %Sattr3{}}— overwrite if exists.{:guarded, %Sattr3{}}— fail withNFS3ERR_EXISTif exists.{:exclusive, createverf3}— atomic create-if-not-exists keyed by the 8-byte verifier. Retried CREATE with the same verf must be idempotent: the second call observes the same file rather thanNFS3ERR_EXIST.
On success returns the new file's optional post_op_fh3 plus its
post_op_attr, plus the parent directory's wcc_data. On failure
returns the parent's wcc_data so the client can refresh its
cache.
@callback fsinfo(Tahr.NFSv3.Types.fhandle3(), Tahr.RPC.Auth.credential(), ctx()) :: {:ok, fsinfo_reply(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
FSINFO — RFC 1813 §3.3.19.
@callback fsstat(Tahr.NFSv3.Types.fhandle3(), Tahr.RPC.Auth.credential(), ctx()) :: {:ok, fsstat_reply(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
FSSTAT — RFC 1813 §3.3.18.
@callback getattr(Tahr.NFSv3.Types.fhandle3(), Tahr.RPC.Auth.credential(), ctx()) :: {:ok, Tahr.NFSv3.Types.Fattr3.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
GETATTR — RFC 1813 §3.3.1.
@callback link( file_fh :: Tahr.NFSv3.Types.fhandle3(), link_dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
LINK — RFC 1813 §3.3.15. Create a hard link from
link_dir_fh / name to file_fh. Servers without hard-link
support return NFS3ERR_NOTSUPP per RFC 1813.
@callback lookup( dir :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.fhandle3(), Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
LOOKUP — RFC 1813 §3.3.3. Returns the looked-up file's handle, its
optional post_op_attr, and the directory's post_op_attr. On
failure, the directory post_op_attr is still returned where
available.
@callback mkdir( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), attrs :: Tahr.NFSv3.Types.Sattr3.t(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.fhandle3() | nil, Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
MKDIR — RFC 1813 §3.3.9. Create a directory at dir_fh / name.
Reply carries the new dir's optional post_op_fh3, optional
post_op_attr, and the parent's wcc_data.
@callback mknod( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.fhandle3() | nil, Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
MKNOD — RFC 1813 §3.3.11. Create a special file (block / char
device, FIFO, or socket). RFC 1813 explicitly allows servers
that don't support special files to return NFS3ERR_NOTSUPP;
most non-POSIX-stack servers do exactly that.
@callback pathconf(Tahr.NFSv3.Types.fhandle3(), Tahr.RPC.Auth.credential(), ctx()) :: {:ok, pathconf_reply(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
PATHCONF — RFC 1813 §3.3.20.
@callback read( Tahr.NFSv3.Types.fhandle3(), Tahr.NFSv3.Types.offset3(), Tahr.NFSv3.Types.count3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, read_reply()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
READ — RFC 1813 §3.3.6. The data field of the success reply must
be a chunk-iterator (Enumerable.t() of binary segments) whose
total bytes equal at most count. Returning a flat binary is
equivalent to a one-element iterator but defeats the streaming
invariant the handler relies on.
@callback readdir( Tahr.NFSv3.Types.fhandle3(), cookie :: Tahr.NFSv3.Types.nfs_cookie3(), cookieverf :: Tahr.NFSv3.Types.cookieverf3(), count :: Tahr.NFSv3.Types.count3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, [readdir_entry()], Tahr.NFSv3.Types.cookieverf3(), eof :: boolean(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
READDIR — RFC 1813 §3.3.16. The handler decides where to splice in
. / ..; the backend just returns its own entries. Cookies
are opaque — the backend may encode position in any way it
prefers.
@callback readdirplus( Tahr.NFSv3.Types.fhandle3(), cookie :: Tahr.NFSv3.Types.nfs_cookie3(), cookieverf :: Tahr.NFSv3.Types.cookieverf3(), dircount :: Tahr.NFSv3.Types.count3(), maxcount :: Tahr.NFSv3.Types.count3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, [readdirplus_entry()], Tahr.NFSv3.Types.cookieverf3(), eof :: boolean(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
READDIRPLUS — RFC 1813 §3.3.17. dircount is the byte budget for
entry names; maxcount is the byte budget for the whole reply
(including post-op attrs and fhandles). The backend should honour
maxcount and stop early if the next entry would push the
budget over.
@callback readlink(Tahr.NFSv3.Types.fhandle3(), Tahr.RPC.Auth.credential(), ctx()) :: {:ok, Tahr.NFSv3.Types.nfspath3(), Tahr.NFSv3.Types.Fattr3.t() | nil} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.Fattr3.t() | nil}
READLINK — RFC 1813 §3.3.5.
@callback remove( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
REMOVE — RFC 1813 §3.3.12. Unlink a file by (dir_fh, name).
Reply carries the parent's wcc_data.
@callback rename( from_dir :: Tahr.NFSv3.Types.fhandle3(), from_name :: Tahr.NFSv3.Types.filename3(), to_dir :: Tahr.NFSv3.Types.fhandle3(), to_name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.WccData.t(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
RENAME — RFC 1813 §3.3.14. Cross-directory rename. The reply
carries wcc_data for both parent directories (fromdir_wcc
and todir_wcc) so the client can refresh the parent caches on
either side.
Backends that fence cross-node renames atomically should return
NFS3ERR_INVAL for cycles like rename(/a, /a/b) and
NFS3ERR_NOTEMPTY if the destination is a non-empty directory.
@callback rmdir( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
RMDIR — RFC 1813 §3.3.13. Remove an empty directory by
(dir_fh, name). Returns NFS3ERR_NOTEMPTY if the directory is
non-empty and NFS3ERR_NOTDIR if the target is not a directory.
Reply carries the parent's wcc_data.
@callback setattr( Tahr.NFSv3.Types.fhandle3(), Tahr.NFSv3.Types.Sattr3.t(), guard_ctime :: Tahr.NFSv3.Types.Nfstime3.t() | nil, Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
SETATTR — RFC 1813 §3.3.2. Apply the requested sattr3 fields
(any field set to nil means "leave unchanged"; :atime /
:mtime carry RFC 1813's time_set semantics —
:set_to_server_time or {:client, %Nfstime3{}}).
When guard_ctime is non-nil, the backend MUST compare it to the
file's current ctime and return {:error, :not_sync, wcc} if
they differ — RFC 1813 §3.3.2's "guarded SETATTR" precondition.
Reply carries wcc_data (pre_op wcc_attr + post_op fattr3)
on both success and failure so the client can refresh its
close-to-open cache. Backends that cannot supply pre- or post-op
attrs may pass nil for either side.
@callback symlink( dir_fh :: Tahr.NFSv3.Types.fhandle3(), name :: Tahr.NFSv3.Types.filename3(), attrs :: Tahr.NFSv3.Types.Sattr3.t(), target :: Tahr.NFSv3.Types.nfspath3(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, Tahr.NFSv3.Types.fhandle3() | nil, Tahr.NFSv3.Types.Fattr3.t() | nil, Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
SYMLINK — RFC 1813 §3.3.10. Create a symbolic link at
dir_fh / name pointing at target (an opaque NFS path string —
resolution is the client's responsibility, the server just stores
bytes). Reply carries the new symlink's optional post_op_fh3,
optional post_op_attr, and the parent's wcc_data.
@callback write( fh :: Tahr.NFSv3.Types.fhandle3(), offset :: Tahr.NFSv3.Types.offset3(), data :: binary(), stable :: Tahr.NFSv3.Types.stable_how(), Tahr.RPC.Auth.credential(), ctx() ) :: {:ok, %{ wcc: Tahr.NFSv3.Types.WccData.t(), count: non_neg_integer(), committed: Tahr.NFSv3.Types.stable_how(), verf: Tahr.NFSv3.Types.writeverf3() }} | {:error, Tahr.NFSv3.Types.nfsstat3(), Tahr.NFSv3.Types.WccData.t()} | {:error, Tahr.NFSv3.Types.nfsstat3()}
WRITE — RFC 1813 §3.3.7. Write data at offset with the
requested stable hint. The server returns the actual count
written (may be less than the request), the stability level it
achieved (must be >= stable requested), and the per-instance
writeverf3. Backends that always commit synchronously may
return :file_sync regardless of the requested stability — the
client gets stronger guarantees than asked for, which RFC 1813
permits.
The handler trims data against the per-write count cap before
this callback fires, so backends can rely on byte_size(data) == count.