feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 18:51:17 +02:00
commit a9b8039ef7
153 changed files with 34403 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Race, Go. Dispatched by hand, and never a gate on a push or a tag: the release tag is
# cut only after `just gates` has already raced the tree, so this workflow is the
# explicit second opinion, not a step of the release.
#
# The race detector roughly doubles both time and memory, which the shared runner box
# cannot afford on every push. Locally it belongs to `just gates`, which runs it once per
# task; here it is a decision rather than a routine.
#
# Every step is one command, so the step that fails is the gate that failed.
name: Race
on:
workflow_dispatch:
env:
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
race:
runs-on: fedora
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install gcc
# The race detector needs cgo and the runner image carries no C compiler.
run: dnf install -y gcc
- name: Race
# The pattern matches `packages` in the project's justfile: the logic
# packages under internal/.
run: go test -race -count=1 -timeout 10m ./internal/...
+420
View File
@@ -0,0 +1,420 @@
# Release, Go binaries. Runs on version tags (v1.2.3) pushed to the repository.
#
# The module must sit at the repository root: the toolchain records a version only for a
# root module, measured on go1.27.1, so a build of a module in a subdirectory reports
# (devel) even at its own <module>/vX.Y.Z tag and this workflow's smoke test can never
# pass for it. A Go repository is one module at the root.
# The version contract these steps implement is in the `release` skill, and its point is
# that nothing is injected: the toolchain records the tag into the binary's build
# information, so the build simply has to happen at the tag, which the trigger guarantees.
#
# The gates run in their own job, once, before the matrix, minus the race detector: race
# never runs on a push path or a tag, and the local gate raced this tree before the tag
# was cut. Putting the gates inside the matrix would run the whole suite once per target
# on the box that also hosts the forge. Each job validates the tag for itself rather than
# passing a value between jobs, so no workflow feature has to be trusted for the version
# to reach the file name.
name: Release
on:
push:
tags: ["v*"]
env:
# The box is shared with the forge, so parallelism is bounded on purpose. The gates job
# needs it most; the build jobs inherit it for their parallel compilation.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
gates:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
# Perl for the steps below. The install is a no-op where the package
# is already present.
run: dnf install -y perl
- name: Validate the tag
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
print qq{tag $v\n};
'
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
run: go fix -diff ./...
- name: Tests
# The pattern matches `packages` in the project's justfile: the logic
# packages under internal/.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
build:
runs-on: fedora
timeout-minutes: 25
needs: gates
strategy:
fail-fast: false
matrix:
# Linux on amd64, arm64, loong64 and riscv64, FreeBSD, OpenBSD and
# NetBSD on amd64 and arm64 (the toolchain carries no riscv64 for
# the BSDs), darwin on arm64 only: Apple Silicon, no Intel macs.
# No 32-bit, no wasm, no Windows. The runner is linux/amd64 and is the only platform
# the suite runs on: every other target is cross compiled here.
# The BSDs carry their guarantee from the local QEMU batteries run
# before a tag; the darwin target carries none on purpose, it
# ships as a cross compiled build without runtime testing.
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: loong64
- goos: linux
goarch: riscv64
- goos: freebsd
goarch: amd64
- goos: freebsd
goarch: arm64
- goos: openbsd
goarch: amd64
- goos: openbsd
goarch: arm64
- goos: netbsd
goarch: amd64
- goos: netbsd
goarch: arm64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
run: dnf install -y perl
- name: Validate the tag
id: version
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
(my $nv = $v) =~ s{^v}{};
open(my $o, q{>>}, $ENV{GITEA_OUTPUT}) or die qq{GITEA_OUTPUT: $!};
print $o qq{version_no_v=$nv\n};
close($o);
print qq{version $nv\n};
'
- name: Build the server
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
# Nothing is injected. The toolchain records the tag into the binary's build
# information, so the version is right because this build happens at the tag, and
# there is no path for anyone to get wrong. -s -w only strips symbols.
go build -ldflags "-s -w" -o "bin/nfsd-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/nfsd
- name: Build the client
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: go build -ldflags "-s -w" -o "bin/nfs-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/nfs
# Artifacts stay on v3: v4 and later detect Gitea as GHES and abort.
- name: Upload the server artifact
uses: actions/upload-artifact@v3
with:
name: nfsd-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/nfsd-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error
- name: Upload the client artifact
uses: actions/upload-artifact@v3
with:
name: nfs-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/nfs-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error
- name: Smoke test the server
# Only a binary matching the runner can be run here. The check is not that --version
# exits cleanly but that it reports the tag and nothing more: a build outside version
# control reports (devel), and a build whose tree was dirty reports +dirty, and both
# would otherwise be published.
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
env:
TAG: ${{ gitea.ref_name }}
BIN: bin/nfsd-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
run: |
perl -e '
my $want = $ENV{TAG} // die qq{ERROR: no tag\n};
open(my $bin, q{-|}, $ENV{BIN}, q{--version}) or die qq{$ENV{BIN}: $!};
my $got = <$bin>;
close($bin);
$got = defined $got ? $got : q{};
chomp $got;
index($got, $want) >= 0
or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n};
index($got, q{+dirty}) < 0
or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n};
print qq{$ENV{BIN} reports $got\n};
'
- name: Smoke test the client
# The client reports its version through the version subcommand; the check
# is the same as the server's: the tag present, +dirty absent.
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
env:
TAG: ${{ gitea.ref_name }}
BIN: bin/nfs-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
run: |
perl -e '
my $want = $ENV{TAG} // die qq{ERROR: no tag\n};
open(my $bin, q{-|}, $ENV{BIN}, q{version}) or die qq{$ENV{BIN}: $!};
my $got = <$bin>;
close($bin);
$got = defined $got ? $got : q{};
chomp $got;
index($got, $want) >= 0
or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n};
index($got, q{+dirty}) < 0
or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n};
print qq{$ENV{BIN} reports $got\n};
'
release:
runs-on: fedora
timeout-minutes: 15
needs: build
permissions:
# contents: read is required for the checkout: a job that declares any
# permissions gets a token scoped to exactly those, and releases: write
# alone leaves the fetch with no read access, which Gitea answers with
# a 404 "Repository not found". Verified on the instance 2026-09-16.
contents: read
releases: write
steps:
- uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: dist
- name: Install Perl
run: dnf install -y perl
- name: Extract the CHANGELOG section
env:
VERSION: ${{ gitea.ref_name }}
run: |
# Each step derives what it needs from the tag, so no value has to travel between
# jobs.
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ s{^v}{};
open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
print $vout $v;
close($vout);
open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!};
my @lines = <$in>;
close($in);
my ($start, $end) = (-1, scalar @lines);
for my $i (0 .. $#lines) {
if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} }
elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last }
}
$start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n};
my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1];
@body or die qq{ERROR: the CHANGELOG section for $v is empty\n};
open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!};
print $out @body;
close($out);
printf qq{notes for %s: %d lines\n}, $v, scalar @body;
'
- name: Build the release request
run: |
perl -e '
open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
my $v = <$vin>;
close($vin);
chomp $v;
open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!};
my $body = do { local $/; <$in> };
close($in);
# Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the
# characters JSON forbids are rewritten.
$body =~ s/([\\"])/\\$1/g;
$body =~ s/\t/\\t/g;
$body =~ s/\r//g;
$body =~ s/\n/\\n/g;
$body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge;
my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body);
open(my $out, q{>}, q{release.json}) or die qq{release.json: $!};
print $out $json;
close($out);
print qq{release.json written for v$v\n};
'
- name: Create the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/json},
q{-X}, q{POST},
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases},
q{--data-binary}, q{@release.json});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
$ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n};
open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!};
my $body = do { local $/; <$r> };
close($r);
$code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n};
$body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n};
open(my $o, q{>}, q{release-id.txt}) or die qq{release-id.txt: $!};
print $o $1;
close($o);
print qq{release id $1\n};
'
- name: Upload assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
open(my $f, q{<}, q{release-id.txt}) or die qq{release-id.txt: $!};
my $id = <$f>;
close($f);
chomp $id;
my @files = grep { -f $_ } glob(q{dist/*/*});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my @cmd = (q{curl}, q{-sS}, q{-o}, q{/dev/null}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/octet-stream},
# The @ must not sit inside a qq{} string: there it starts an
# array interpolation and the upload body collapses to empty,
# which Gitea stores as a 201-created zero-byte attachment.
q{-X}, q{POST}, q{--data-binary}, q{@} . $path,
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases/$id/assets?name=$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
unless ($ok) {
printf qq{%s: curl failed (exit %d)\n}, $name, $exit;
$bad = 1;
next;
}
printf qq{%s: HTTP %s\n}, $name, $code;
$bad = 1 if $code ne q{201};
}
exit($bad ? 1 : 0);
'
- name: Verify the assets
# HTTP 201 alone lies: the upload can create the attachment and
# still store it empty, and a green run over an empty asset is a
# release nobody can install. Every asset is read back through
# the release download route and its served length must equal
# the sent length.
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
TAG: ${{ gitea.ref_name }}
run: |
perl -e '
my @files = grep { -f $_ } glob(q{dist/*/*});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my $body = qq{verify-$name};
my @cmd = (q{curl}, q{-sS}, q{-o}, $body, q{-w}, q{%{http_code}},
qq{$ENV{GITEA_SERVER_URL}/$ENV{GITEA_REPOSITORY}/releases/download/$ENV{TAG}/$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
$code = defined $code ? $code : q{};
$ok or do { printf qq{%s: the read back failed\n}, $name; $bad = 1; next };
$code eq q{200} or do { printf qq{%s: HTTP %s on the read back\n}, $name, $code; $bad = 1; next };
my $sent = -s $path;
my $got = -s $body;
if (!defined $sent || !defined $got || $sent != $got) {
printf qq{%s: served %s bytes, sent %s\n}, $name, $got // q{?}, $sent // q{?};
$bad = 1;
next;
}
printf qq{%s: %s bytes verified\n}, $name, $got;
}
exit($bad ? 1 : 0);
'
+97
View File
@@ -0,0 +1,97 @@
# Test, Go. Push and pull request to development. Never on main.
# The standard repository points this trigger at development; the main-only
# exception, which deploys on every push and has no release, points it at main.
#
# The gates are the ones the justfile's `gates` recipe runs, minus race: the shared
# runner box cannot afford the race detector on every push, so it lives in race.yml.
# The box is one core and 2 GB beside Gitea, so parallelism is bounded on purpose and
# everything runs in one job. Extra jobs would duplicate the checkout, the Go setup and
# the dependency download three times without buying any parallelism.
#
# Every step is one command, so the step that fails is the gate that failed, and no shell
# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and
# not Python: Perl behaves the same on both runner images, there is no bashism to trip over
# on ash, and it is one language instead of two. The Perl uses builtins only, because
# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be
# assumed present.
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
env:
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
# A superseded run of the same ref is cancelled instead of queueing behind one that
# no longer matters. Verified on Gitea 1.27.1 on 2026-09-17: a queued run whose ref
# moved on is cancelled before it ever reaches the runner, while a run already
# dispatched there runs to completion.
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
# The module is the source of truth for the version, so it cannot drift.
go-version-file: go.mod
cache: true
- name: Install Perl
# The runner images are minimal and Perl is not guaranteed. The install is a
# no-op where it is already present; drop this step once verified on the box.
run: dnf install -y perl
# The steps follow the `gates` order of the justfile contract: build, format,
# vet, test. The vet gate is go vet and go fix -diff, two steps here.
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
# Exits non-zero when it has something to rewrite, so it needs no output capture.
run: go fix -diff ./...
- name: Tests
# The suite must be fast: a push pipeline that cannot finish in a few minutes moves
# its heavy part behind a dispatch. The inner timeout matches the job's, so a
# hanging test reports its own goroutine dump rather than a silent job kill.
# The pattern matches `packages` in the project's justfile: the logic packages
# under internal/. The thin cmd binaries count as zero coverage and would drag
# the floor below 80 percent on their own.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
+15
View File
@@ -0,0 +1,15 @@
.idea/
.zcode/
# Build output.
bin/
# Coverage profiles and other tooling output.
coverage.out
# Go test binaries.
*.test
# Stray binaries from a bare go build in the root.
/nfs
/nfsd
+134
View File
@@ -0,0 +1,134 @@
# Changelog
All notable changes to **nfs** are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [development]
### Added
-
## [1.0.0] - 2026-09-21
### Added
The first release of a full NFSv4.2 implementation in pure Go: server and client,
minor version 2 on the wire only, no portmapper, no mountd, no separate locking
protocol.
- **Wire foundation**: the XDR codec of RFC 4506, ONC RPC record marking of
RFC 5531 with fragment reassembly, the call and reply headers, AUTH_SYS, and
the NFSv4.2 operation, error and attribute numbers verified against the
standards and the Linux client header.
- **Stateless operations**: PUTROOTFH, PUTFH, SAVEFH, RESTOREFH, GETFH, LOOKUP,
LOOKUPP, PUTPUBFH, GETATTR, ACCESS, READ, READDIR, WRITE, CREATE, REMOVE,
RENAME, SETATTR, LINK, READLINK, COMMIT, VERIFY, NVERIFY, SECINFO and
SECINFO_NO_NAME, over a virtual filesystem with a local directory backend.
- **Sessions**: EXCHANGE_ID, CREATE_SESSION, DESTROY_SESSION, BIND_CONN_TO_SESSION
and SEQUENCE with a slot table, a reply cache per slot, and client reboot
detection that drops the state of the previous life.
- **Open state**: OPEN and CLOSE with real stateids, share reservations enforced
across opens of the same file, OPEN_DOWNGRADE, and regular file creation
through the unchecked, guarded and exclusive forms, where an exclusive create
replays by its verifier.
- **Ownership**: every object a client creates carries the identity the client
presented, and the server answers the owner and owner group of every object,
so ownership reads correctly on any conformant client.
- **Byte range locking**: LOCK, LOCKT and LOCKU with per owner conflict
detection, range splitting on unlock, and RELEASE_LOCKOWNER.
- **Lease and recovery**: lease renewal on every SEQUENCE, DESTROY_CLIENTID,
RECLAIM_COMPLETE inside the grace window, CLAIM_PREVIOUS reclamation, and
persistent file handle maps that survive a server restart.
- **Delegations**: read and write delegations granted on the only open of a
file, recalled over the back channel on a conflicting open, returned through
DELEGRETURN.
- **Directory delegations**: GET_DIR_DELEGATION with CB_NOTIFY on create,
rename and remove, and CB_NOTIFY_LOCK when a released range frees a denied
lock.
- **Back channel**: CB_COMPOUND over the same TCP connection, CB_SEQUENCE,
CB_RECALL, and callback delivery that the client demultiplexes from replies.
- **Optional operations of RFC 7862**: SEEK, ALLOCATE, DEALLOCATE, IO_ADVISE,
READ_PLUS, WRITE_SAME, COPY, CLONE, COPY_NOTIFY, OFFLOAD_CANCEL,
OFFLOAD_STATUS, LAYOUTERROR and LAYOUTSTATS.
- **Extended attributes**: GETXATTR, SETXATTR, LISTXATTR and REMOVEXATTR of
RFC 8276 over the user namespace of the local backend.
- **Named attributes**: OPENATTR with create, lookup, read, write and remove
over the synthetic attribute directory of an object.
- **pNFS**: the metadata server role with LAYOUTGET, LAYOUTCOMMIT,
LAYOUTRETURN, GETDEVICEINFO and GETDEVICELIST, and layout bodies for
flexfiles (RFC 8435), files, block volumes, objects and SCSI, all over one
emulated device that is the metadata server itself. The flexfiles version 2
body of draft-haynes-nfsv4-flex-filesv2-00 (layout type 0x6) is served the
same way.
- **Migration and referrals**: the fs_locations and fs_locations_info
attributes, referral stubs whose other operations answer NFS4ERR_MOVED.
- **Kerberos**: RPCSEC_GSS with the krb5, krb5i and krb5p service levels, the
AES profiles of RFC 3961 and RFC 3962 and the tokens of RFC 4121 implemented
in pure Go, plus the version three credential of RFC 7861 with CREATE, LIST
and assertion binding.
- **RPC-with-TLS**: the AUTH_TLS probe and in place TLS upgrade of RFC 9289.
- **RPC-over-RDMA framing**: the chunk lists and message assembly of RFC 8166
over a stream transport; the verbs transport itself sits outside pure Go.
- **The nfsd command**: the `-export` directory and the `-addr` listen
address, a TOML configuration file through `-config` carrying the listen
address, the operation log, the state directory, the connection cap, the
TLS key pair and one `[[export]]`, with the flags overriding the file and a
broken file ending the start up with the file and the line named; a read
only export with `-ro`; RPC-with-TLS through `-tls-cert` and `-tls-key`,
where a client that skips STARTTLS is refused with auth too weak for every
procedure but the NULL of the probe; the operation log of `-log-ops`; the
connection cap of `-max-connections`; persistent recovery state through
`-state-dir`, where file handles and opens are written as they change, a
restart loads them back and the grace window lets clients reclaim their
opens with CLAIM_PREVIOUS; root squash with `-root-squash` or `root-squash`
in the export, mapping a client claiming uid 0 onto nobody (65534);
`READY=1` on $NOTIFY_SOCKET once the listener is up, so a `Type=notify`
unit starts on real readiness; version reporting; and a clean shutdown on
SIGINT and SIGTERM.
- **The nfs command**: ls, cat, put, get, rm, mkdir and stat against a running
server; the whole operation matrix as `nfs selftest`, one line per check
plus a summary and a nonzero exit when a check fails; transfers spread over
several session slots with `-concurrency`, measured on loopback at a 64 MiB
put dropping from 77 ms sequential to 32 ms at four; a session owner id
unique to the process, so two clients of the command beside each other are
two clients, not one rebooting; and the version report.
- **Kernel interop**: the server is verified end to end against the Linux
kernel NFSv4.2 client, which mounts the tree over `mount -t nfs4` and reads,
writes, creates and removes through it, with correct ownership, as root and
non root callers alike.
- **Interoperability stance**: strict conformance as the rule, a documented
tolerance layer confined to the deviations of real clients, and the server
and client pair as the strict reference of the stack.
- **Performance**: the server answers a COMPOUND from one buffer instead of
copying every operation result twice, READ fills the reply in place, WRITE
hands the request's own bytes to the storage and the wire buffers recycle;
the local backend keeps open descriptors of regular files in a bounded
cache and revalidates the file identity on every use; READDIR pages cost
the page against a cached sorted order of the directory; COPY and CLONE run
through copy_file_range and the reflink of the filesystem with a fallback
to the userspace copy; the session store locks per session instead of one
server wide mutex and the lease check runs lock free against an atomic
renewal stamp, six clients beside each other measured at 4.8 times the
sequential throughput. Measured numbers: 11 percent faster reads, 19
percent fewer allocations per COMPOUND, 27 percent fewer bytes per read,
16.9 percent faster reads and 11.0 percent faster writes of 64 KiB chunks,
and a READDIR page of 64 entries in a 10 000 entry directory measured 30
times faster; the reports live in docs/_results/.
- **Operations**: docs/DEPLOYMENT.md carries the production picture, the
hardened systemd unit with Type=notify and the two capability model, the
firewall note and the upgrade and monitoring story; docs/CONFIGURATION.md
lists every configuration key; docs/BENCHMARKING.md states the measurement
method.
- **Platforms**: Linux on amd64, arm64, loong64 and riscv64; FreeBSD, OpenBSD
and NetBSD on amd64 and arm64, all three verified live on amd64, OpenBSD
and NetBSD with both ends of the project running inside the system and
across to the Linux server because their kernel clients speak only NFSv3;
darwin on arm64 as a cross compiled build without runtime testing. Extended
attributes and the sparse operations answer not supported on OpenBSD,
NetBSD and darwin, whose local backends have no system interface an
arbitrary attribute name could use. The stack is pure Go end to end, and
the module ships the two commands only: there is no importable package and
no library surface.
+120
View File
@@ -0,0 +1,120 @@
# Contributing
Contributions to **nfs** are governed by the Contributor terms
below; submitting one means you accept them.
## Contributor terms
1. This project belongs to its owner alone. The owner decides what is
accepted, in what form and when; the decision is final and needs no
justification.
2. By submitting a contribution you assign to Petr Balvín
<opensource@petrbalvin.org> all present and future copyright and
related rights in it, worldwide, for the full term of the rights,
with the right to relicense and sublicense without restriction,
including under proprietary terms.
3. Where that assignment is not effective, it counts as a perpetual,
irrevocable, royalty-free licence with the same scope.
4. To the fullest extent permitted by law, you waive any right of
attribution and integrity in the contribution. The project names no
contributors and keeps no credits list.
5. By submitting you represent that the work is yours and that you
hold the rights to assign it as above.
## Development setup
Requirements: Go 1.27.1, the newest stable release, and
[just](https://github.com/casey/just) for the recipes. The race detector in
`just gates` needs a C compiler, so gcc must be installed.
```sh
git clone https://sourcedock.dev/petrbalvin/nfs.git
cd nfs
just build
just test
```
## Workflow
1. Branch from `development`. Never commit directly to `main`, which is release-only.
2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form:
`type(scope): description`, subject line only, imperative mood, lowercase after the
colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`,
`refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`.
3. One logical change per commit. A refactor, a behaviour change and a formatting pass
are three commits, never one.
4. Record every user-visible change in `CHANGELOG.md` under `## [development]`.
5. Add or update tests. Coverage stays at 80 percent or more; it is a hard gate.
6. Update the documentation when the public API, the configuration or the behaviour
changes.
7. Never commit while `just gates` is red; run it locally first.
8. Open a pull request against `development`.
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The release
workflow builds the assets and publishes the release and its notes.
## Code style
`gofmt` and `go vet` run through `just fmt` and `just vet`, with zero diff and zero
warnings tolerated. `just vet` also runs `go fix -diff`, so modernisations are part of
the gate and not a follow-up. `just gates` is the definition of done in one command,
and the recipe file names what it contains. Errors are checked explicitly, wrapped as
`fmt.Errorf("context: %w", err)`, and nothing panics outside `main`; the exported
surface, every identifier and every message reads in British English. The recipe
file holds the commands.
New source files open with the project's two-line licence header, whose SPDX
identifier matches `LICENSE`. Configuration files, workflows and dotfiles do not carry
it.
## AI contribution policy
AI tools are welcome as productivity aids and are a normal part of modern software
development. What matters is that the contribution stays understandable, reviewable and
genuinely useful.
- **Disclose the assistance.** If AI helped draft any part of a commit, issue, pull
request or review, say so.
- **Commit messages carry exactly one trailer**, on the line after the subject:
```
Assisted-by: MODEL
```
Name the model that did the work, spelled the way its maker spells it, for example
`GLM 5.3`, `DeepSeek V4.1 Flash` or `Qwen 3.8 Flash`. No `Co-Authored-By`, no `Signed-off-by`,
no other trailers, and no prose: the trailer is the disclosure.
- **Issues and pull requests** attribute the assistance in a comment, for example
`_Assisted-by: GLM 5.3_`. It does not belong in the pull request description.
- **Take responsibility.** You are accountable for the accuracy, completeness and
intent of everything you submit, whether or not AI produced it.
- **Review before marking ready.** Read the diff carefully, run it locally, and add the
tests it needs. Do not mark a pull request ready until you can defend every change in
it.
- **Quality over quantity.** Contributions that look like un-reviewed output, or whose
author cannot engage substantively during review, may be closed.
- **Preferred models.** Prefer open-weight models with transparent training data and
minimal output filtering.
AI assists. It does not replace judgement.
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners:
| Workflow | Trigger | What it does |
|---|---|---|
| Test | push or pull request to `development` | build, format check, vet, modernisation, the test suite with the coverage floor |
| Race | dispatched by hand | the suite under the race detector, as a second opinion after the local gate |
| Release | a `v*` tag | the same gates as Test, then the matrix build, the proven version and the release itself; the race detector runs locally in `just gates` before the tag is cut |
The local equivalent is `just gates`, which is the same set plus the race detector.
## Reporting bugs
Open an issue at `https://sourcedock.dev/petrbalvin/nfs/issues` with the
version, the operating system and architecture, the exact command, the full output,
and the expected against the actual behaviour.
**Security issues do not go in the issue tracker.** Report them as
[SECURITY.md](SECURITY.md) describes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170
View File
@@ -0,0 +1,170 @@
# NFSv4.2 server and client in pure Go
One project, two programs: `nfsd` serves a local directory tree over
NFSv4.2, and `nfs` speaks to any NFSv4.2 server from the command line.
Both are written against RFC 8881 and RFC 7862 and speak minor version 2
only, with no portmapper, no mountd and no separate locking protocol:
NFSv4 carries all of it on one TCP port.
Status: feature complete against the scope in docs/ARCHITECTURE.md. The
full surface is implemented: stateless operations, sessions, locking,
delegations, the optional operations of RFC 7862, extended attributes,
pNFS with five layout families, directory delegations, Kerberos
(RPCSEC_GSS and GSSv3) and RPC-with-TLS, all in pure Go. The server is
verified end to end against the Linux kernel NFSv4.2 client: `mount -t
nfs4` mounts the tree, and reads, writes, creates, removals and
attribute ownership behave through it for root and non root callers
alike. The same holds for the FreeBSD kernel client on amd64:
`mount_nfs` mounts the tree and the same battery passes.
## Platforms
Linux on amd64, arm64, loong64 and riscv64; FreeBSD, OpenBSD and NetBSD
on amd64 and arm64; darwin on arm64. The stack is pure Go
whose one dependency outside the standard library is interpres, the
TOML reader of the server configuration, itself pure Go, so other
platforms stay within reach without a rewrite. All three BSDs are
verified live on amd64: FreeBSD through its kernel client mounting the
tree over NFSv4.2, OpenBSD and NetBSD with both ends of the project
running inside the system and across to the Linux server, because
their kernel clients speak only NFSv3, which sits outside the project's
NFSv4.2 scope. The arm64 assets ship cross compiled. Extended
attributes and the sparse operations answer not supported on OpenBSD
and NetBSD, whose local backends have no system interface an arbitrary
attribute name could use. The darwin target builds from the same tree
and ships cross compiled; it carries no runtime testing, and extended
attributes and the sparse operations answer not supported there.
## Interoperability
NFS is a protocol between independent implementations, and real clients
carry their own reading of the specification. During development and
testing this server was exercised against the most widely deployed NFS
client, the Linux kernel client, and that client deviates from RFC 8881:
it presents delegation state that no live server granted and never
recovers it, so a server that answers the specification's
NFS4ERR_BAD_STATEID without a tolerance layer holds the client in an
endless retry loop. On the most widespread platform of all, strict
conformance alone produces incompatibility.
The answer here is a tolerance layer confined to documented seams, each
one named in the code with the deviation it absorbs, and a wire that is
otherwise held strictly to RFC 8881 and RFC 7862. Between this server and
this client the protocol runs with no deviation at all: every tolerance
path sits dormant, because both ends speak exactly as the specification
is written. The pair is the strict reference of the stack, an
implementation that keeps behaviour and validity consistent across
platforms and a baseline any other implementation can be measured
against.
## Features
- **XDR codec**: the primitive encoding of RFC 4506, with append style
encoding and bounds checked decoding
- **ONC RPC**: record marking per RFC 5531 with fragment reassembly, the
call and reply headers, and AUTH_SYS credentials
- **NFSv4.2 wire vocabulary**: operation and error numbers, the attribute
table, and the COMPOUND procedure codec
- **Stateless server core**: PUTROOTFH, PUTFH, SAVEFH, RESTOREFH, GETFH,
LOOKUP, GETATTR, ACCESS, READ, READDIR, WRITE, CREATE, REMOVE,
RENAME, SETATTR, LINK, READLINK, COMMIT, SECINFO and SECINFO_NO_NAME
over a virtual filesystem
- **Byte range locking**: LOCK, LOCKT and LOCKU with per-owner conflict
detection and range splitting on unlock
- **Lease and client lifecycle**: OPEN_DOWNGRADE, DESTROY_CLIENTID and
RECLAIM_COMPLETE with the RFC-mandated second-answer rejection
- **OPEN delegations**: read and write delegations granted on the only
open of a file, recalled over the back channel on a conflicting open
- **Back channel**: the client demultiplexes callback calls from replies
on the same connection and answers them
- **Sessions**: the RFC 8881 slot table with at-most-once execution, a
reply cache per slot, and client reboot detection
- **OPEN and CLOSE**: real stateids, share reservations enforced across
opens of the same file, and regular file creation through the unchecked
and guarded forms
- **Local backend**: one local directory tree served behind the nfsfs
interface, with ino based file handles and a read write half that creates
directories, symlinks, fifos, sockets and device nodes and writes file
data
- **nfsd**: the server binary, with version reporting and a clean shutdown
- **nfs**: the client command: ls, cat, put and stat against a running
server, plus the version report
- **pNFS**: the metadata server role with flexfiles, files, block,
objects and SCSI layout bodies over one emulated device, GETDEVICEINFO and
GETDEVICELIST
- **Extended attributes**: GETXATTR, SETXATTR, LISTXATTR and REMOVEXATTR
end to end over the user namespace of the local backend
- **Optional operations of RFC 7862**: SEEK, ALLOCATE, DEALLOCATE,
IO_ADVISE, READ_PLUS, WRITE_SAME, COPY, CLONE, COPY_NOTIFY, OFFLOAD_CANCEL,
OFFLOAD_STATUS, LAYOUTERROR and LAYOUTSTATS
- **Migration and referrals**: the fs_locations and fs_locations_info
attributes with NFS4ERR_MOVED stubs
- **Named attributes**: OPENATTR with create, lookup, read, write and remove
over the synthetic directory
- **Directory delegations**: GET_DIR_DELEGATION with CB_NOTIFY on create,
rename and remove, and CB_NOTIFY_LOCK when a denied lock frees
- **Kerberos**: RPCSEC_GSS with krb5, krb5i and krb5p: the AES profiles of
RFC 3961/3962 and the tokens of RFC 4121 in pure Go, plus the version
three credential of RFC 7861 with assertion binding
- **RPC-with-TLS**: the AUTH_TLS probe and in place connection upgrade of
RFC 9289
- **nfsclient**: the client package behind the nfs command; it is also
the second oracle against the server
## Install
From source:
```sh
git clone https://sourcedock.dev/petrbalvin/nfs.git
cd nfs
just build
```
The binaries land in `bin/nfsd` and `bin/nfs`.
## Quick start
```sh
./bin/nfsd -version
mkdir -p /srv/demo && echo "ahoj" > /srv/demo/hello.txt
./bin/nfsd -export /srv/demo -addr 127.0.0.1:2049
```
```sh
./bin/nfs -addr 127.0.0.1:2049 ls
./bin/nfs -addr 127.0.0.1:2049 cat /hello.txt
./bin/nfs -addr 127.0.0.1:2049 put README.md /readme.md
```
The server refuses to start without an export, serves the tree read and
write on the address given, enforcing the permissions of the files
against the identity each client presents, and exits on SIGINT and
SIGTERM; clients retry through their session replay caches, so a stopped
server costs no state. The client speaks to any NFSv4.2 server on the
address given, this one included.
## Development
```sh
just build # build
just test # the test suite
just fmt # format
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): prerequisites and recipes
- [docs/CLI.md](docs/CLI.md): the command line reference
- [man/nfsd.1](man/nfsd.1) and [man/nfs.1](man/nfs.1): the manpages of the
two commands
## Licence
MIT. See [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+33
View File
@@ -0,0 +1,33 @@
# Security policy
## Supported versions
Security fixes go to the newest release and to the `development` branch. Older
releases do not receive them.
## Reporting a vulnerability
**Do not open a public issue for a security problem.** A public report tells everyone
about the flaw before there is a fix. Report it privately to
**opensource@petrbalvin.org**.
Include:
- the version or commit you tested, and the platform
- what the problem is, and what an attacker gains from it
- the smallest reproducer you have, ideally a test or a single command
- a suggested fix, if you have one
## What to expect
- A human reads the report, and you get an acknowledgement.
- You are kept informed while the fix is being made, and told when it ships.
- The fix is released before the details are published, and the timing is agreed with
you.
- The reporter is credited in the release notes, unless anonymity is requested.
## Out of scope
- Findings that require the attacker to already run code on the server host, or to
hold local access to it.
- Missing hardening with no demonstrated impact.
+265
View File
@@ -0,0 +1,265 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"sync/atomic"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// putChunk is the size of one in flight read or write of get and put,
// the same megabyte the commands have always transferred per compound.
const putChunk = 1 << 20
// cmdGet mirrors a remote file into a local file through READ compounds,
// up to workers of them in flight. The local file is created first, so a
// shorter remote leaves no tail behind.
func cmdGet(cl *nfsclient.Client, remote, local string, workers int) error {
out, err := os.Create(local)
if err != nil {
return err
}
defer out.Close()
var next atomic.Int64
var stop atomic.Bool
type chunk struct {
idx int64
data []byte
eof bool
}
done := make(chan error, 1)
pages := make(chan chunk, workers)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for {
if stop.Load() {
return
}
idx := next.Add(1) - 1
ops := append(pathOps(remote),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, uint64(idx)*putChunk, putChunk))
res, bodies, err := cl.Compound("get", ops)
if err != nil {
done <- err
stop.Store(true)
return
}
if res.Status != nfs4.ErrOK {
done <- fmt.Errorf("get: nfs status %d", res.Status)
stop.Store(true)
return
}
body, err := bodyAt("get", bodies, len(bodies)-1)
if err != nil {
done <- err
stop.Store(true)
return
}
d := xdr.NewDecoder(body)
eof, err := d.Bool()
if err != nil {
done <- err
stop.Store(true)
return
}
data, err := d.VarOpaque()
if err != nil {
done <- err
stop.Store(true)
return
}
pages <- chunk{idx: idx, data: data, eof: eof}
if eof {
stop.Store(true)
return
}
}
})
}
go func() { wg.Wait(); close(pages) }()
var total uint64
for page := range pages {
if _, err := out.WriteAt(page.data, page.idx*putChunk); err != nil {
return err
}
total += uint64(len(page.data))
if page.eof {
stop.Store(true)
}
}
select {
case err := <-done:
return err
default:
}
fmt.Printf("wrote %d bytes from %s\n", total, remote)
return nil
}
// cmdPut writes a local file to the server through OPEN and WRITE,
// up to workers of them in flight after the truncate.
func cmdPut(cl *nfsclient.Client, local, remote string, workers int) error {
in, err := os.Open(local)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
parts := splitPath(remote)
if len(parts) == 0 {
return fmt.Errorf("put: empty remote path")
}
name := parts[len(parts)-1]
dirOps := pathOps(strings.Join(parts[:len(parts)-1], "/"))
openOps := append(dirOps,
nfs4.AppendOpenArgs(nil, 0, []byte("nfs-cli"), nfs4.ShareAccessBoth, 0,
true, 0o644, name),
nfs4.AppendGetfh(nil))
res, bodies, err := cl.Compound("put-open", openOps)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("put: open status %d", res.Status)
}
var st nfs4.Stateid
stateBody, err := bodyAt("put", bodies, len(bodies)-2)
if err != nil {
return err
}
copy(st[:], stateBody)
fhBody, err := bodyAt("put", bodies, len(bodies)-1)
if err != nil {
return err
}
fh, err := xdr.NewDecoder(fhBody).VarOpaque()
if err != nil {
return err
}
// PUT replaces the whole file: the size is zeroed through SETATTR
// before the first write, so a shorter file leaves no tail behind.
tres, _, err := cl.Compound("put-truncate", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendSetattrArgs(nil, nfs4.AllZero, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{}),
})
if err != nil {
return err
}
if tres.Status != nfs4.ErrOK {
return fmt.Errorf("put: truncate status %d", tres.Status)
}
chunks := (info.Size() + putChunk - 1) / putChunk
var next atomic.Int64
var stop atomic.Bool
done := make(chan error, 1)
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
buf := make([]byte, putChunk)
for {
if stop.Load() {
return
}
idx := next.Add(1) - 1
if idx >= chunks {
return
}
n, rerr := in.ReadAt(buf, idx*putChunk)
if rerr != nil && !errors.Is(rerr, os.ErrClosed) {
// A short final read is the file's end, not a failure.
if !errors.Is(rerr, io.EOF) {
done <- rerr
stop.Store(true)
return
}
}
wres, _, err := cl.Compound("put-write", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendWriteArgs(nil, st, uint64(idx)*putChunk, nfs4.StableFileSync, buf[:n]),
})
if err != nil {
done <- err
stop.Store(true)
return
}
if wres.Status != nfs4.ErrOK {
done <- fmt.Errorf("put: write status %d", wres.Status)
stop.Store(true)
return
}
}
})
}
wg.Wait()
select {
case err := <-done:
return err
default:
}
cres, _, err := cl.Compound("put-close", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendCloseArgs(nil, st),
})
if err != nil {
return err
}
if cres.Status != nfs4.ErrOK {
return fmt.Errorf("put: close status %d", cres.Status)
}
fmt.Printf("wrote %d bytes to %s\n", info.Size(), remote)
return nil
}
// cmdRm removes one object from the server through REMOVE.
func cmdRm(cl *nfsclient.Client, path string) error {
parts := splitPath(path)
if len(parts) == 0 {
return errors.New("rm: empty path")
}
dir := strings.Join(parts[:len(parts)-1], "/")
ops := append(pathOps(dir), nfs4.AppendRemoveArgs(nil, parts[len(parts)-1]))
res, _, err := cl.Compound("rm", ops)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("rm: nfs status %d", res.Status)
}
return nil
}
// cmdMkdir makes one directory on the server through CREATE NF4DIR.
func cmdMkdir(cl *nfsclient.Client, path string) error {
parts := splitPath(path)
if len(parts) == 0 {
return errors.New("mkdir: empty path")
}
dir := strings.Join(parts[:len(parts)-1], "/")
ops := append(pathOps(dir),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, parts[len(parts)-1], "", 0, 0, 0o755))
res, _, err := cl.Compound("mkdir", ops)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("mkdir: nfs status %d", res.Status)
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command nfs is the client binary of the nfs project: a protocol client
// over TCP with a small set of operations, built on the nfsclient
// library that carries the full NFSv4.2 surface.
package main
import (
"flag"
"fmt"
"os"
"runtime/debug"
"strings"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func main() {
addr := flag.String("addr", "127.0.0.1:2049", "server address")
concurrency := flag.Int("concurrency", 1, "compounds in flight for get and put, 1 to 8; 1 keeps the transfers sequential")
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
os.Exit(2)
}
var err error
switch args[0] {
case "version":
fmt.Println(buildVersion())
return
case "ls":
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdLs(cl, pathArg(args, 1)) })
case "cat":
if len(args) < 2 {
usage()
os.Exit(2)
}
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdCat(cl, args[1]) })
case "put":
if len(args) < 3 {
usage()
os.Exit(2)
}
err = run(*addr, *concurrency, func(cl *nfsclient.Client) error { return cmdPut(cl, args[1], args[2], *concurrency) })
case "get":
if len(args) < 3 {
usage()
os.Exit(2)
}
err = run(*addr, *concurrency, func(cl *nfsclient.Client) error { return cmdGet(cl, args[1], args[2], *concurrency) })
case "rm":
if len(args) < 2 {
usage()
os.Exit(2)
}
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdRm(cl, args[1]) })
case "mkdir":
if len(args) < 2 {
usage()
os.Exit(2)
}
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdMkdir(cl, args[1]) })
case "stat":
if len(args) < 2 {
usage()
os.Exit(2)
}
err = run(*addr, 1, func(cl *nfsclient.Client) error { return cmdStat(cl, args[1]) })
case "selftest":
err = run(*addr, 1, cmdSelftest)
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintf(os.Stderr, "nfs: %v\n", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: nfs [-addr host:port] version | ls [path] | cat path | put local remote | get remote local | rm path | mkdir path | stat path | selftest")
}
// run dials the server, establishes a session and hands the connection
// to one command.
func run(addr string, concurrency int, cmd func(*nfsclient.Client) error) error {
cl, err := nfsclient.Dial(addr)
if err != nil {
return err
}
defer cl.Close()
// The owner id is the client identity on the server: two processes
// sharing one owner look like the same client rebooting, so every
// run names itself with its process and a fresh stamp.
owner := fmt.Sprintf("nfs-cli-%d-%d", os.Getpid(), time.Now().UnixNano())
if err := cl.Establish(owner); err != nil {
return err
}
if concurrency != 1 {
if err := cl.SetConcurrency(concurrency); err != nil {
return err
}
}
return cmd(cl)
}
// pathOps builds the operation prefix that walks from the root to a
// path; the empty path addresses the root itself.
func pathOps(path string) [][]byte {
ops := [][]byte{nfs4.AppendPutRootfh(nil)}
for _, part := range splitPath(path) {
ops = append(ops, nfs4.AppendLookup(nil, part))
}
return ops
}
func splitPath(path string) []string {
return strings.FieldsFunc(strings.Trim(path, "/"), func(r rune) bool { return r == '/' })
}
func pathArg(args []string, i int) string {
if len(args) > i {
return args[i]
}
return ""
}
// bodyAt answers one result body of a compound the client ran, guarding
// the shape a hostile or broken server answered before anything indexes
// it.
func bodyAt(cmd string, bodies [][]byte, i int) ([]byte, error) {
if i < 0 || i >= len(bodies) {
return nil, fmt.Errorf("%s: the server answered %d results", cmd, len(bodies))
}
return bodies[i], nil
}
// cmdLs lists one directory.
func cmdLs(cl *nfsclient.Client, path string) error {
ops := append(pathOps(path), nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode)))
res, bodies, err := cl.Compound("ls", ops)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("ls: nfs status %d", res.Status)
}
body, err := bodyAt("ls", bodies, len(bodies)-1)
if err != nil {
return err
}
_, entries, _, err := nfs4.DecodeReadDirBody(body)
if err != nil {
return err
}
for _, e := range entries {
fmt.Printf("%s\t%d\t%o\n", e.Name, e.Attrs.Size, e.Attrs.Mode&0o7777)
}
return nil
}
// cmdCat streams a file to stdout.
func cmdCat(cl *nfsclient.Client, path string) error {
var offset uint64
for {
ops := append(pathOps(path),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, offset, 1<<20))
res, bodies, err := cl.Compound("cat", ops)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("cat: nfs status %d", res.Status)
}
body, err := bodyAt("cat", bodies, len(bodies)-1)
if err != nil {
return err
}
d := xdr.NewDecoder(body)
eof, err := d.Bool()
if err != nil {
return err
}
data, err := d.VarOpaque()
if err != nil {
return err
}
if _, err := os.Stdout.Write(data); err != nil {
return err
}
offset += uint64(len(data))
if eof {
return nil
}
}
}
// cmdStat prints the attributes of one object.
func cmdStat(cl *nfsclient.Client, path string) error {
request := nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode,
nfs4.AttrTimeModify)
ops := append(pathOps(path), nfs4.AppendGetattr(nil, request))
res, bodies, err := cl.Compound("stat", ops)
if err != nil {
return err
}
if res.Status != nfs4.ErrOK {
return fmt.Errorf("stat: nfs status %d", res.Status)
}
body, err := bodyAt("stat", bodies, len(bodies)-1)
if err != nil {
return err
}
d := xdr.NewDecoder(body)
if _, err := nfs4.ReadBitmap(d); err != nil {
return err
}
blob, err := d.VarOpaque()
if err != nil {
return err
}
attrs, err := nfs4.DecodeFattrAttrs(blob, request)
if err != nil {
return err
}
fmt.Printf("type %d\nsize %d\nmode %o\nmtime %d.%09d\n",
attrs.Type, attrs.Size, attrs.Mode&0o7777,
attrs.TimeModify.Seconds, attrs.TimeModify.Nseconds)
return nil
}
// buildVersion reports the module version the toolchain recorded at
// build time, falling back to a development label.
func buildVersion() string {
if info, ok := debug.ReadBuildInfo(); ok {
return info.Main.Version
}
return "(devel)"
}
+455
View File
@@ -0,0 +1,455 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"bytes"
"errors"
"fmt"
"strings"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// selftestChunk is the size of the write and compare payload of the
// battery: the same 64 KiB the reference benchmarks use.
const selftestChunk = 64 << 10
// a selftestCheck is one named test of the battery.
type selftestCheck struct {
name string
run func(*nfsclient.Client, string) error
}
// cmdSelftest runs the whole operation matrix against the running server
// the session points at, prints one line per check and a summary, and
// fails when any check fails. The work directory is removed on success
// and left behind on failure, so a failing server can be examined.
func cmdSelftest(cl *nfsclient.Client) error {
dir := fmt.Sprintf("selftest-%d", time.Now().UnixNano())
checks := []selftestCheck{
{"mkdir work directory", selfMkdir},
{"touch empty file", selfTouch},
{"write 64 KiB", selfWrite},
{"read back and compare 64 KiB", selfCompare},
{"list directory", selfList},
{"rename", selfRename},
{"symlink and readlink", selfSymlink},
{"nested directory", selfNested},
{"ownership of new files", selfOwnership},
{"setattr mode", selfSetattr},
{"remove files", selfRemove},
{"remove work directory", selfRemoveWorkdir},
}
var failed int
for _, check := range checks {
err := check.run(cl, dir)
if err != nil {
failed++
fmt.Printf("FAIL %s: %v\n", check.name, err)
continue
}
fmt.Printf("PASS %s\n", check.name)
}
fmt.Printf("%d/%d checks passed against %s\n", len(checks)-failed, len(checks), dir)
if failed > 0 {
return errors.New("selftest: the battery failed; the work directory is left in place")
}
return nil
}
// selfCompound runs one COMPOUND and requires success of it.
func selfCompound(cl *nfsclient.Client, tag string, ops [][]byte) (nfs4.CompoundRes, [][]byte, error) {
res, bodies, err := cl.Compound(tag, ops)
if err != nil {
return res, bodies, err
}
if res.Status != nfs4.ErrOK {
return res, bodies, fmt.Errorf("%s: nfs status %d", tag, res.Status)
}
return res, bodies, nil
}
// selfAttrs reports the attributes of one path.
func selfAttrs(cl *nfsclient.Client, path string) (nfs4.Attrs, error) {
request := nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode,
nfs4.AttrNumlinks, nfs4.AttrOwner)
_, bodies, err := selfCompound(cl, "selftest-getattr",
append(pathOps(path), nfs4.AppendGetattr(nil, request)))
if err != nil {
return nfs4.Attrs{}, err
}
return decodeAttrs(bodies[len(bodies)-1], request)
}
// decodeAttrs decodes one GETATTR result body.
func decodeAttrs(body []byte, request nfs4.Bitmap) (nfs4.Attrs, error) {
d := xdr.NewDecoder(body)
if _, err := nfs4.ReadBitmap(d); err != nil {
return nfs4.Attrs{}, err
}
blob, err := d.VarOpaque()
if err != nil {
return nfs4.Attrs{}, err
}
return nfs4.DecodeFattrAttrs(blob, request)
}
// selfOpenNew opens a new file for writing, the way put does, and
// returns the handle and the stateid.
func selfOpenNew(cl *nfsclient.Client, path string, perm uint32) ([]byte, nfs4.Stateid, error) {
parts := splitPath(path)
dir, name := strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1]
_, bodies, err := selfCompound(cl, "selftest-open", append(pathOps(dir),
nfs4.AppendOpenArgs(nil, 0, []byte("selftest"), nfs4.ShareAccessBoth, 0,
true, perm, name),
nfs4.AppendGetfh(nil)))
if err != nil {
return nil, nfs4.Stateid{}, err
}
fhBody, err := bodyAt("selftest", bodies, len(bodies)-1)
if err != nil {
return nil, nfs4.Stateid{}, err
}
fh, err := xdr.NewDecoder(fhBody).VarOpaque()
if err != nil {
return nil, nfs4.Stateid{}, err
}
var st nfs4.Stateid
stateBody, err := bodyAt("selftest", bodies, len(bodies)-2)
if err != nil {
return nil, nfs4.Stateid{}, err
}
if len(stateBody) < len(st) {
return nil, nfs4.Stateid{}, fmt.Errorf("open result carries %d bytes of stateid", len(stateBody))
}
copy(st[:], stateBody)
return fh, st, nil
}
// selfClose closes one open file.
func selfClose(cl *nfsclient.Client, fh []byte, st nfs4.Stateid) error {
_, _, err := selfCompound(cl, "selftest-close", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendCloseArgs(nil, st),
})
return err
}
// selfMkdir makes the work directory and confirms it is a directory.
func selfMkdir(cl *nfsclient.Client, dir string) error {
parts := splitPath(dir)
if _, _, err := selfCompound(cl, "selftest-mkdir", append(pathOps(""),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, parts[0], "", 0, 0, 0o755))); err != nil {
return err
}
attrs, err := selfAttrs(cl, dir)
if err != nil {
return err
}
if attrs.Type != nfs4.NF4Dir {
return fmt.Errorf("type %d, want a directory", attrs.Type)
}
return nil
}
// selfTouch creates an empty file through OPEN and confirms its size.
func selfTouch(cl *nfsclient.Client, dir string) error {
fh, st, err := selfOpenNew(cl, dir+"/empty", 0o644)
if err != nil {
return err
}
if err := selfClose(cl, fh, st); err != nil {
return err
}
attrs, err := selfAttrs(cl, dir+"/empty")
if err != nil {
return err
}
if attrs.Size != 0 {
return fmt.Errorf("size %d, want 0", attrs.Size)
}
return nil
}
// selfPattern returns the deterministic payload chunk the battery writes
// and compares.
func selfPattern() []byte {
buf := make([]byte, selftestChunk)
for i := range buf {
buf[i] = byte(i * 7)
}
return buf
}
// selfWrite opens a fresh file and writes one 64 KiB pattern through it.
func selfWrite(cl *nfsclient.Client, dir string) error {
fh, st, err := selfOpenNew(cl, dir+"/data", 0o644)
if err != nil {
return err
}
_, _, err = selfCompound(cl, "selftest-write", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendWriteArgs(nil, st, 0, nfs4.StableFileSync, selfPattern()),
})
if err != nil {
return err
}
return selfClose(cl, fh, st)
}
// selfCompare reads the written file back in one READ loop and compares
// it byte for byte with the pattern.
func selfCompare(cl *nfsclient.Client, dir string) error {
got, err := readWhole(cl, dir+"/data")
if err != nil {
return err
}
if !bytes.Equal(got, selfPattern()) {
return fmt.Errorf("%d bytes read back, content differs", len(got))
}
return nil
}
// readWhole streams one file to memory, the way cat does.
func readWhole(cl *nfsclient.Client, path string) ([]byte, error) {
var out []byte
var offset uint64
for {
_, bodies, err := selfCompound(cl, "selftest-read", append(pathOps(path),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, offset, selftestChunk)))
if err != nil {
return nil, err
}
d := xdr.NewDecoder(bodies[len(bodies)-1])
eof, err := d.Bool()
if err != nil {
return nil, err
}
data, err := d.VarOpaque()
if err != nil {
return nil, err
}
out = append(out, data...)
offset += uint64(len(data))
if eof {
return out, nil
}
}
}
// selfList lists the work directory and expects the two files of the
// earlier checks.
func selfList(cl *nfsclient.Client, dir string) error {
_, bodies, err := selfCompound(cl, "selftest-readdir", append(pathOps(dir),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode))))
if err != nil {
return err
}
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
if err != nil {
return err
}
names := make(map[string]bool, len(entries))
for _, e := range entries {
names[e.Name] = true
}
for _, want := range []string{"empty", "data"} {
if !names[want] {
return fmt.Errorf("entry %s missing from the listing", want)
}
}
return nil
}
// selfRename moves data to data2 within the work directory and confirms
// the old name is gone.
func selfRename(cl *nfsclient.Client, dir string) error {
if _, _, err := selfCompound(cl, "selftest-rename", append(pathOps(dir),
nfs4.AppendSavefh(nil),
nfs4.AppendRenameArgs(nil, "data", "data2"))); err != nil {
return err
}
if _, err := selfAttrs(cl, dir+"/data"); err == nil {
return errors.New("the old name still resolves")
}
attrs, err := selfAttrs(cl, dir+"/data2")
if err != nil {
return err
}
if attrs.Size != selftestChunk {
return fmt.Errorf("size %d after the move, want %d", attrs.Size, selftestChunk)
}
return nil
}
// selfSymlink creates a symlink and reads its target back.
func selfSymlink(cl *nfsclient.Client, dir string) error {
if _, _, err := selfCompound(cl, "selftest-symlink", append(pathOps(dir),
nfs4.AppendCreateArgs(nil, nfs4.NF4Lnk, "link", "data2", 0, 0, 0))); err != nil {
return err
}
_, bodies, err := selfCompound(cl, "selftest-readlink", append(pathOps(dir+"/link"),
nfs4.AppendReadlinkArgs(nil)))
if err != nil {
return err
}
target, err := xdr.NewDecoder(bodies[len(bodies)-1]).String()
if err != nil {
return err
}
if target != "data2" {
return fmt.Errorf("target %q, want data2", target)
}
return nil
}
// selfNested makes a directory inside the work directory, a file inside
// it, lists both and removes the inner file again.
func selfNested(cl *nfsclient.Client, dir string) error {
if _, _, err := selfCompound(cl, "selftest-mkdir-nested", append(pathOps(dir),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "inner", "", 0, 0, 0o755))); err != nil {
return err
}
fh, st, err := selfOpenNew(cl, dir+"/inner/file", 0o644)
if err != nil {
return err
}
if err := selfClose(cl, fh, st); err != nil {
return err
}
_, bodies, err := selfCompound(cl, "selftest-readdir-nested", append(pathOps(dir+"/inner"),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode))))
if err != nil {
return err
}
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
if err != nil {
return err
}
if len(entries) != 1 || entries[0].Name != "file" {
return fmt.Errorf("%d entries in the nested listing, want exactly file", len(entries))
}
if _, _, err := selfCompound(cl, "selftest-remove-nested", append(pathOps(dir+"/inner"),
nfs4.AppendRemoveArgs(nil, "file"))); err != nil {
return err
}
return nil
}
// selfOwnership creates a file as uid 1234 in a world writable scratch
// directory and confirms the object carries that owner. A server that
// runs as root hands the identity over; a server without the privilege
// keeps its own, which is the documented fallback of the service model,
// so the owner of the scratch directory names the accepted alternative.
func selfOwnership(cl *nfsclient.Client, dir string) error {
scratch := dir + "/owned"
if _, _, err := selfCompound(cl, "selftest-owned-dir", append(pathOps(dir),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "owned", "", 0, 0, 0o755))); err != nil {
return err
}
if _, _, err := selfCompound(cl, "selftest-owned-mode", append(pathOps(scratch),
nfs4.AppendSetattrArgs(nil, nfs4.Stateid{}, nfs4.OfBits(nfs4.AttrMode),
nfs4.Attrs{Mode: 0o777}))); err != nil {
return err
}
workdir, err := selfAttrs(cl, dir)
if err != nil {
return err
}
cl.SetIdentity(1234, 1234, []uint32{1234})
fh, st, err := selfOpenNew(cl, scratch+"/file", 0o666)
if err != nil {
cl.SetIdentity(0, 0, nil)
return err
}
if err := selfClose(cl, fh, st); err != nil {
cl.SetIdentity(0, 0, nil)
return err
}
cl.SetIdentity(0, 0, nil)
attrs, err := selfAttrs(cl, scratch+"/file")
if err != nil {
return err
}
if attrs.Owner != "1234" && attrs.Owner != workdir.Owner {
return fmt.Errorf("owner %q, want 1234 or the service identity %q",
attrs.Owner, workdir.Owner)
}
return nil
}
// selfSetattr changes the mode of a file and confirms the change. The
// SETATTR walks to the file fresh and carries the zero stateid.
func selfSetattr(cl *nfsclient.Client, dir string) error {
_, _, err := selfCompound(cl, "selftest-setattr", append(pathOps(dir+"/empty"),
nfs4.AppendSetattrArgs(nil, nfs4.Stateid{}, nfs4.OfBits(nfs4.AttrMode),
nfs4.Attrs{Mode: 0o600})))
if err != nil {
return err
}
after, err := selfAttrs(cl, dir+"/empty")
if err != nil {
return err
}
if after.Mode&0o777 != 0o600 {
return fmt.Errorf("mode %o after the change, want 600", after.Mode&0o777)
}
return nil
}
// selfRemove takes the files and the nested directory out of the work
// directory and confirms the listing is empty.
func selfRemove(cl *nfsclient.Client, dir string) error {
for _, name := range []string{"empty", "data2", "link"} {
if _, _, err := selfCompound(cl, "selftest-remove", append(pathOps(dir),
nfs4.AppendRemoveArgs(nil, name))); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
}
if _, _, err := selfCompound(cl, "selftest-remove-owned", append(pathOps(dir+"/owned"),
nfs4.AppendRemoveArgs(nil, "file"))); err != nil {
return fmt.Errorf("owned/file: %w", err)
}
if _, _, err := selfCompound(cl, "selftest-rmdir-owned", append(pathOps(dir),
nfs4.AppendRemoveArgs(nil, "owned"))); err != nil {
return fmt.Errorf("owned: %w", err)
}
if _, _, err := selfCompound(cl, "selftest-rmdir-nested", append(pathOps(dir),
nfs4.AppendRemoveArgs(nil, "inner"))); err != nil {
return err
}
_, bodies, err := selfCompound(cl, "selftest-readdir-final", append(pathOps(dir),
nfs4.AppendReaddir(nil, 0, [8]byte{}, 1<<20, 1<<20,
nfs4.OfBits(nfs4.AttrType))))
if err != nil {
return err
}
_, entries, _, err := nfs4.DecodeReadDirBody(bodies[len(bodies)-1])
if err != nil {
return err
}
if len(entries) != 0 {
return fmt.Errorf("%d entries left in the work directory", len(entries))
}
return nil
}
// selfRemoveWorkdir removes the work directory itself and confirms it is
// gone.
func selfRemoveWorkdir(cl *nfsclient.Client, dir string) error {
if _, _, err := selfCompound(cl, "selftest-rmdir", append(pathOps(""),
nfs4.AppendRemoveArgs(nil, dir))); err != nil {
return err
}
if _, err := selfAttrs(cl, dir); err == nil {
return errors.New("the work directory still resolves")
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"flag"
"fmt"
"os"
"sourcedock.dev/petrbalvin/interpres/v2"
)
// A tlsFile is the [tls] table of the configuration file.
type tlsFile struct {
Cert string `toml:"cert"`
Key string `toml:"key"`
}
// An exportFile is one [[export]] table of the configuration file.
type exportFile struct {
Path string `toml:"path"`
ReadOnly bool `toml:"read-only"`
RootSquash bool `toml:"root-squash"`
}
// A fileConfig mirrors the whole configuration file. Every key is
// optional; a key the file leaves out keeps the flag or the built in
// default.
type fileConfig struct {
Listen string `toml:"listen"`
LogOps bool `toml:"log-ops"`
StateDir string `toml:"state-dir"`
MaxConnections int `toml:"max-connections"`
TLS *tlsFile `toml:"tls"`
Exports []exportFile `toml:"export"`
}
// loadConfig reads and validates one configuration file. An empty path
// reads nothing and answers the zero configuration: the file is never
// read unless the operator names it.
func loadConfig(path string) (fileConfig, error) {
var cfg fileConfig
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := interpres.Unmarshal(data, &cfg, interpres.RejectUnknownFields(true)); err != nil {
if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
return cfg, fmt.Errorf("%s:%d:%d: %s", path, se.Line, se.Column, se.Msg)
}
return cfg, fmt.Errorf("%s: %v", path, err)
}
if len(cfg.Exports) != 1 {
return cfg, fmt.Errorf("%s: exactly one [[export]] is required, found %d", path, len(cfg.Exports))
}
if cfg.Exports[0].Path == "" {
return cfg, fmt.Errorf("%s: [[export]] names no path", path)
}
return cfg, nil
}
// applyConfig folds the file configuration under the flags: the file is
// the base, every flag the operator passed overrides it. The visited set
// is the flags present on the command line, defaults included.
func applyConfig(cfg fileConfig, fs *flag.FlagSet, set func(string) bool) {
if v := cfg.Listen; v != "" && !set("addr") {
_ = fs.Set("addr", v)
}
if len(cfg.Exports) == 1 && !set("export") {
_ = fs.Set("export", cfg.Exports[0].Path)
if cfg.Exports[0].ReadOnly && !set("ro") {
_ = fs.Set("ro", "true")
}
if cfg.Exports[0].RootSquash && !set("root-squash") {
_ = fs.Set("root-squash", "true")
}
}
if cfg.LogOps && !set("log-ops") {
_ = fs.Set("log-ops", "true")
}
if v := cfg.StateDir; v != "" && !set("state-dir") {
_ = fs.Set("state-dir", v)
}
if v := cfg.MaxConnections; v != 0 && !set("max-connections") {
_ = fs.Set("max-connections", fmt.Sprint(v))
}
if cfg.TLS != nil && !set("tls-cert") && !set("tls-key") {
if cfg.TLS.Cert != "" {
_ = fs.Set("tls-cert", cfg.TLS.Cert)
}
if cfg.TLS.Key != "" {
_ = fs.Set("tls-key", cfg.TLS.Key)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/interpres/v2"
)
const sampleConfig = `listen = ":2049"
log-ops = true
state-dir = "/var/lib/nfsd"
max-connections = 64
[tls]
cert = "/etc/nfsd/cert.pem"
key = "/etc/nfsd/key.pem"
[[export]]
path = "/srv/demo"
read-only = true
`
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "nfsd.toml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return p
}
// TestLoadConfig reads the whole sample back, every table and key.
func TestLoadConfig(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != ":2049" || !cfg.LogOps || cfg.StateDir != "/var/lib/nfsd" ||
cfg.MaxConnections != 64 {
t.Fatalf("scalars: %+v", cfg)
}
if cfg.TLS == nil || cfg.TLS.Cert != "/etc/nfsd/cert.pem" || cfg.TLS.Key != "/etc/nfsd/key.pem" {
t.Fatalf("tls: %+v", cfg.TLS)
}
if len(cfg.Exports) != 1 || cfg.Exports[0].Path != "/srv/demo" || !cfg.Exports[0].ReadOnly {
t.Fatalf("exports: %+v", cfg.Exports)
}
}
// TestLoadConfigEmptyPath covers the explicitness rule: no -config, no
// file read, zero configuration.
func TestLoadConfigEmptyPath(t *testing.T) {
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Listen != "" || len(cfg.Exports) != 0 {
t.Fatalf("an empty path answered %+v", cfg)
}
}
// TestLoadConfigUnknownKey rejects a key the schema does not carry: a
// typo in the file must end the start up, not slip through.
func TestLoadConfigUnknownKey(t *testing.T) {
_, err := loadConfig(writeConfig(t, "lisn = \":2049\"\n"))
if err == nil {
t.Fatal("an unknown key was accepted")
}
var se *interpres.SyntaxError
if !errors.As(err, &se) && !strings.Contains(err.Error(), "lisn") &&
!strings.Contains(err.Error(), "unknown") {
t.Fatalf("the error names neither the key nor its kind: %v", err)
}
}
// TestLoadConfigSyntaxError reports the file and the line of a broken
// document.
func TestLoadConfigSyntaxError(t *testing.T) {
p := writeConfig(t, "listen = \":2049\"\nbroken")
_, err := loadConfig(p)
if err == nil {
t.Fatal("a broken document was accepted")
}
if !strings.Contains(err.Error(), p+":2:") {
t.Fatalf("the error misses the file or the line: %v", err)
}
}
// TestLoadConfigOneExport enforces the exactly one export contract.
func TestLoadConfigOneExport(t *testing.T) {
if _, err := loadConfig(writeConfig(t, "listen = \":2049\"")); err == nil {
t.Fatal("a file without an export was accepted")
}
if _, err := loadConfig(writeConfig(t,
"[[export]]\npath = \"/a\"\n\n[[export]]\npath = \"/b\"\n")); err == nil {
t.Fatal("a file with two exports was accepted")
}
if _, err := loadConfig(writeConfig(t, "[[export]]\nread-only = true\n")); err == nil {
t.Fatal("an export without a path was accepted")
}
}
// newTestFlags builds the flag set of main with the same names and
// defaults.
func newTestFlags() (addr, export *string, ro, logOps *bool, tlsCert, tlsKey, stateDir *string,
maxConns *int, fs *flag.FlagSet) {
fs = flag.NewFlagSet("nfsd-test", flag.ContinueOnError)
addr = fs.String("addr", ":2049", "")
export = fs.String("export", "", "")
ro = fs.Bool("ro", false, "")
tlsCert = fs.String("tls-cert", "", "")
tlsKey = fs.String("tls-key", "", "")
logOps = fs.Bool("log-ops", false, "")
stateDir = fs.String("state-dir", "", "")
maxConns = fs.Int("max-connections", 0, "")
return
}
// TestApplyConfigFillsUnsetFlags: the file is the base.
func TestApplyConfigFillsUnsetFlags(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
addr, export, ro, logOps, tlsCert, tlsKey, stateDir, maxConns, fs := newTestFlags()
applyConfig(cfg, fs, func(string) bool { return false })
if *addr != ":2049" || *export != "/srv/demo" || !*ro || !*logOps ||
*stateDir != "/var/lib/nfsd" || *maxConns != 64 ||
*tlsCert != "/etc/nfsd/cert.pem" || *tlsKey != "/etc/nfsd/key.pem" {
t.Fatalf("flags after the file: addr=%q export=%q ro=%v logOps=%v stateDir=%q maxConns=%d tls=%q,%q",
*addr, *export, *ro, *logOps, *stateDir, *maxConns, *tlsCert, *tlsKey)
}
}
// TestApplyConfigFlagsWin: the flags override the file.
func TestApplyConfigFlagsWin(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("load: %v", err)
}
addr, export, ro, logOps, tlsCert, tlsKey, stateDir, maxConns, fs := newTestFlags()
fs.Parse([]string{"-addr", ":9999", "-export", "/other", "-ro=false", "-log-ops=false"})
given := make(map[string]bool)
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
applyConfig(cfg, fs, func(name string) bool { return given[name] })
if *addr != ":9999" {
t.Fatalf("addr %q, the command line must win", *addr)
}
if *export != "/other" {
t.Fatalf("export %q, the command line must win", *export)
}
if *ro {
t.Fatal("ro must stay false, the command line set it")
}
if *logOps {
t.Fatal("log-ops must stay false, the command line set it")
}
if *stateDir != "/var/lib/nfsd" || *maxConns != 64 || *tlsCert == "" || *tlsKey == "" {
t.Fatalf("the file lost the keys the command line left alone: stateDir=%q maxConns=%d tls=%q,%q",
*stateDir, *maxConns, *tlsCert, *tlsKey)
}
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command nfsd serves NFS over TCP. It is the server binary of the nfs
// project: it exports one local directory tree over NFSv4.2 as described
// in docs/ARCHITECTURE.md.
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"runtime/debug"
"syscall"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/server"
)
func main() {
log.SetFlags(0)
flags := flag.CommandLine
addr := flags.String("addr", ":2049", "TCP address to listen on")
export := flags.String("export", "", "directory to serve")
readOnly := flags.Bool("ro", false, "serve the export read only: every mutation answers NFS4ERR_ROFS")
rootSquash := flags.Bool("root-squash", false, "map a client claiming uid 0 onto nobody (65534), so root acts as the anonymous identity")
tlsCert := flags.String("tls-cert", "", "certificate chain in PEM for RPC-with-TLS; requires -tls-key")
tlsKey := flags.String("tls-key", "", "private key in PEM for RPC-with-TLS; requires -tls-cert")
logOps := flags.Bool("log-ops", false, "log every operation with its status and duration to stderr")
maxConns := flags.Int("max-connections", 0, "cap on live connections; a connection above the cap closes at once; 0 means no cap")
stateDir := flags.String("state-dir", "", "directory for persisted client state: handles and opens survive a restart, and a grace window follows it")
configPath := flags.String("config", "", "configuration file in TOML; never read unless named, the flags override it")
version := flags.Bool("version", false, "print the version and exit")
flags.Parse(os.Args[1:])
// The configuration file is the base, the flags override it: only
// the flags present on the command line keep their value, everything
// else yields to the file.
if *configPath != "" {
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
given := make(map[string]bool)
flags.Visit(func(f *flag.Flag) { given[f.Name] = true })
applyConfig(cfg, flags, func(name string) bool { return given[name] })
}
if *version {
fmt.Println(buildVersion())
return
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if *export == "" {
log.Fatalf("nfsd: no export given: pass -export DIR")
}
backend, err := nfsfs.NewLocal(*export)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
var fs nfsfs.FS = backend
if *readOnly {
fs = nfsfs.ReadOnly(backend)
}
if *stateDir != "" {
if err := os.MkdirAll(*stateDir, 0o700); err != nil {
log.Fatalf("nfsd: %v", err)
}
// The handle map is the backend half of the recovery state: load
// what a previous life wrote, then keep writing as handles are
// minted, so a restart resolves what it served before.
if err := backend.LoadPersistedHandles(*stateDir); err != nil {
log.Fatalf("nfsd: %v", err)
}
backend.SetPersistPath(*stateDir)
}
var tlsCfg *tls.Config
if *tlsCert != "" || *tlsKey != "" {
if *tlsCert == "" || *tlsKey == "" {
log.Fatalf("nfsd: -tls-cert and -tls-key go together")
}
cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
tlsCfg = &tls.Config{Certificates: []tls.Certificate{cert}}
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
log.Fatalf("nfsd: %v", err)
}
log.Printf("nfsd: serving %s on %s", *export, ln.Addr())
// The listener is the moment the service can answer: a Type=notify
// unit learns it here.
notifyReadyOrLog()
srv := &server.Server{
Handle: (&nfs4server.Handler{
FS: fs,
TLSConfig: tlsCfg,
LogOps: *logOps,
StateDir: *stateDir,
RootSquash: *rootSquash,
}).HandleConn,
MaxConns: *maxConns,
}
if err := srv.Serve(ctx, ln); err != nil {
log.Fatalf("nfsd: %v", err)
}
}
// buildVersion reports the module version the toolchain recorded at build
// time. A build made at a tag reports the tag; a build outside version
// control reports devel.
func buildVersion() string {
v := "devel"
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "(devel)" {
v = bi.Main.Version
}
return v
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"log"
"net"
"os"
)
// notifyReady announces readiness to the service manager over
// $NOTIFY_SOCKET, the raw unix datagram socket of the sd_notify
// protocol. The message is the one READY=1 line; nothing else is sent
// and no dependency is pulled in. Without the variable, the process is
// not running under a Type=notify unit and the step is skipped quietly.
func notifyReady() error {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" {
return nil
}
conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: addr, Net: "unixgram"})
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.Write([]byte("READY=1")); err != nil {
return err
}
return nil
}
// notifyReadyOrLog announces readiness and reports a failure on the log
// without ending the service: a manager that expects the notification
// times the unit out, but the server itself is ready to serve either way.
func notifyReadyOrLog() {
if err := notifyReady(); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("nfsd: the readiness notification failed: %v", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package main
import (
"net"
"path/filepath"
"testing"
"time"
)
// TestNotifyReadyWithoutSocket covers the quiet path: no NOTIFY_SOCKET,
// no notification, no error. This is every run outside a Type=notify
// unit.
func TestNotifyReadyWithoutSocket(t *testing.T) {
t.Setenv("NOTIFY_SOCKET", "")
if err := notifyReady(); err != nil {
t.Fatalf("notify without a socket: %v", err)
}
}
// expectReady reads one datagram and reports whether it is READY=1.
func expectReady(t *testing.T, ln *net.UnixConn) {
t.Helper()
buf := make([]byte, 64)
ln.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err := ln.ReadFrom(buf)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(buf[:n]) != "READY=1" {
t.Fatalf("message %q, want READY=1", buf[:n])
}
}
// TestNotifyReadyDelivers covers the pathname socket form.
func TestNotifyReadyDelivers(t *testing.T) {
sock := filepath.Join(t.TempDir(), "notify.sock")
ln, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
t.Setenv("NOTIFY_SOCKET", sock)
if err := notifyReady(); err != nil {
t.Fatalf("notify: %v", err)
}
expectReady(t, ln)
}
// TestNotifyReadyAbstract covers the abstract namespace form, the one
// systemd hands over on Linux.
func TestNotifyReadyAbstract(t *testing.T) {
addr := "@" + filepath.Base(t.TempDir()) + "-notify"
ln, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: addr, Net: "unixgram"})
if err != nil {
t.Skipf("abstract unix sockets are unavailable: %v", err)
}
defer ln.Close()
t.Setenv("NOTIFY_SOCKET", addr)
if err := notifyReady(); err != nil {
t.Fatalf("notify: %v", err)
}
expectReady(t, ln)
}
+119
View File
@@ -0,0 +1,119 @@
# Architecture
How nfs is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
flowchart TD
nfs[cmd/nfs] --> Client[internal/nfsclient]
nfsd[cmd/nfsd] --> Server[internal/server]
nfsd --> Backend[internal/nfsfs]
Server --> Dispatch[internal/nfs4server]
Dispatch --> Backend
Dispatch --> Wire[internal/nfs4]
Dispatch --> Record[internal/rpc]
Client --> Wire
Client --> Record
Wire --> XDR[internal/xdr]
Record --> XDR
```
The project speaks NFSv4.2 only, on one TCP port, with the full state model
of RFC 8881 under the extensions of RFC 7862 and the add-ons of RFC 8276,
RFC 7861 and RFC 9289. The server carries the stateless operations, the
session machinery, open and lock state, delegations with their back channel
recalls, the optional operations, extended and named attributes, directory
delegations with notifications, pNFS in the metadata server role, and the
security layers: RPCSEC_GSS v1 and v3 with Kerberos in pure Go, and
RPC-with-TLS with in place connection upgrade. The client mirrors the same
surface and serves as the second oracle against the server. The two
commands carry the roles: nfsd serves one local directory tree, nfs speaks
to a server from the command line.
## Packages
| Package | Responsibility |
|---|---|
| `cmd/nfsd` | flags, the version report, the signal wiring; no logic |
| `cmd/nfs` | the client command: the argument handling and the compound building for ls, cat, put and stat |
| `internal/server` | the accept loop, connection lifetime and shutdown |
| `internal/nfs4server` | the COMPOUND dispatcher: the file handle register, the session machinery, the operations, the mapping of backend errors to statuses |
| `internal/nfs4` | the NFSv4.2 wire vocabulary: numbers, bitmap4, fattr4, the COMPOUND codec and the per operation arguments and results |
| `internal/nfsfs` | the virtual filesystem interface and the local backend with dev and ino based handles |
| `internal/rpc` | ONC RPC: record marking, the call and reply headers, AUTH_SYS credentials |
| `internal/xdr` | the RFC 4506 primitives: integers, booleans, opaque values and strings with their padding |
| `internal/nfsclient` | the client half; it shares the wire packages with the server and serves as the second oracle |
| `internal/krb5` | the Kerberos crypto profiles and GSS tokens of RFC 3961, 3962, 4120 and 4121, verified against the test vectors of the RFCs and the MIT krb5 suite |
| `internal/rdma` | the RPC-over-RDMA framing of RFC 8166: the fixed header, the chunk lists and the stream adapter; verbs live outside pure Go |
The boundaries follow the layering of the protocol stack: a layer speaks
only to the one below it, and the wire packages know nothing about sockets.
The dispatcher decides nothing about storage; the backend interface owns
that.
## Data flow
The main operation today is one COMPOUND through the whole stack, from the
client that is also the project's oracle:
```mermaid
sequenceDiagram
participant C as internal/nfsclient
participant S as nfsd
participant D as internal/nfs4server
participant F as internal/nfsfs
C->>S: record: COMPOUND, PUTROOTFH, LOOKUP, GETFH, GETATTR, READ
S->>D: the record is reassembled, the call decoded
D->>F: Root, Lookup, Getattr, Read
F-->>D: handle, attributes, bytes
D-->>C: one result per operation, the failing one ends the array
```
READDIR carries the paging machinery of the standard: the client's cookie
and verifier are checked against the backend's order, the entries are
packed under the maxcount budget, and the response reports where the next
page starts. The error paths are named: a clean end of stream before the
first header is io.EOF, a stream that stops mid record is
io.ErrUnexpectedEOF, a record beyond the limit is ErrRecordTooLarge, and
every backend failure becomes the NFS4ERR status its sentinel names.
## State and lifetime
- The Server lives for the process and Serve blocks for that long; a
cancellation of the context closes the listener and Serve returns nil.
- Each accepted connection runs on its own goroutine and is owned by its
Handle.
- The file handle register is per COMPOUND: PUTROOTFH, PUTFH, SAVEFH and
RESTOREFH move handles through it, and nothing of it survives the call.
- Session state lives in the session store: the slot table with the reply
cache, the lease of every client, the open and lock state with their
stateids, delegations, directory delegations and the GSS contexts of
RPCSEC_GSS. Referral stubs and named attribute handles are synthetic and
live in their own stores.
- The handles of the local backend encode the device and inode number and
resolve through an in memory map persisted on demand: a handle from
before a server restart resolves again once the mapping is loaded back,
and one whose object is gone answers NFS4ERR_STALE.
- WRITE answers FILE_SYNC with the boot verifier, so no unstable writes
outlive a restart and the client keeps no replay debt. The stateid of a
WRITE is validated: a real one must name a live OPEN of the current file,
and the anonymous forms pass without state.
- WRITE and CREATE reach the backend through the nfsfs Writer interface; a
backend that does not implement it is answered NFS4ERR_ROFS. The local
backend implements both halves: CREATE makes directories, symlinks,
fifos, sockets and device nodes, and a regular file is the business of
OPEN.
## Dependencies
One dependency outside the standard library: interpres
(`sourcedock.dev/petrbalvin/interpres/v2`), the TOML reader of the
configuration file, itself built on the standard library alone. The
protocol stack is carried by hand written code, because the server
targets Linux on amd64, arm64, loong64 and riscv64; FreeBSD,
OpenBSD and NetBSD on amd64 and arm64; and darwin on arm64,
and a dependency
that breaks one of those platforms is a dependency the project cannot
carry.
+49
View File
@@ -0,0 +1,49 @@
# Benchmarking
How the nfs project is measured. Every number a document, a README or a changelog
quotes comes from here and nowhere else.
## The method
- Two levels are measured, and they answer different questions. The backend level
benchmarks the `internal/nfsfs` filesystem layer on its own: what one READ, WRITE,
GETATTR or LOOKUP costs against a local directory. The wire level benchmarks whole
NFS sessions: the server binary and the client library over a loopback connection,
which adds the RPC, XDR and session layers on top of the backend.
- What is deliberately left out: kernel NFS mounts, the network beyond loopback, and
any comparison against other NFS servers. Those are interop questions, not
benchmark questions.
- The machine is idle, named, and stays the same across comparable reports.
- The toolchain is named with its version and its build flags.
- Comparisons run inside one process, with the order of the two sides alternated
where a comparison is the point. Differences under two percent are noise, not
results.
- A change is measured against its baseline, not against a memory of how fast it
used to be. The baseline run is part of the measurement, and both sides land in
the same report.
## Running
```sh
just bench
```
The recipe sweeps `./internal/...` with `-benchmem -count=5`.
A first look at one target, before the full battery is worth the time:
```sh
go test -run '^$' -bench 'BenchmarkRead64K' -benchtime=1x ./internal/nfsfs
```
The full battery runs once, deliberately, on an idle machine. A benchmark command
is capped at about two minutes per round; longer sweeps are split.
## Reports
Reports live in `docs/_results/`, one file per measurement round, named
`YYYY-MM-DD-subject.md`, and follow [BENCHMARK_TEMPLATE.md](BENCHMARK_TEMPLATE.md).
A report carries its numbers, its machine, its toolchain and the exact command. A
number without its provenance is not a result, and a performance claim without a
report behind it is left out of the documentation rather than softened into an
adjective.
+90
View File
@@ -0,0 +1,90 @@
# Command line
The reference below is taken from the programs' own help output. If the two
disagree, the programs are right and this file is a defect. Both commands
carry a manpage: [man/nfsd.1](../man/nfsd.1) and [man/nfs.1](../man/nfs.1).
## Synopsis
```sh
nfsd [-addr addr] [-export dir] [-version]
nfs [-addr host:port] [-concurrency 1-8] version | ls [path] | cat path | put local remote | get remote local | rm path | mkdir path | stat path | selftest
```
## nfsd
The server exports one local directory tree over NFSv4.2 on a single TCP
port and serves it read and write. It exits cleanly on SIGINT and SIGTERM.
| Flag | Default | Effect |
|---|---|---|
| `-addr` | `:2049` | the TCP address to listen on |
| `-export` | | the directory to serve; required, and the path must be an existing directory |
| `-ro` | `false` | serve the export read only: every mutation answers NFS4ERR_ROFS, the reads of every half work unchanged |
| `-root-squash` | `false` | map a client claiming uid 0 onto nobody (65534): no superuser grant, and objects root creates carry nobody |
| `-tls-cert` | | the certificate chain in PEM for RPC-with-TLS (RFC 9289); requires `-tls-key` |
| `-tls-key` | | the private key in PEM for RPC-with-TLS; requires `-tls-cert` |
| `-log-ops` | `false` | log every operation to stderr as `nfs: OP status N duration` |
| `-max-connections` | `0` | cap on live connections; a connection above the cap closes at once; `0` means no cap |
| `-state-dir` | | directory for persisted client state: file handles and opens are written there as they change, a restart loads them back and serves a grace window, so a client reclaims its open with CLAIM_PREVIOUS; without it nothing persists |
| `-config` | | configuration file in TOML, see [CONFIGURATION.md](CONFIGURATION.md); never read unless named, the flags override it |
| `-version` | | print the version and exit |
```sh
$ ./bin/nfsd -export /srv/demo -addr 127.0.0.1:2049
nfsd: serving /srv/demo on 127.0.0.1:2049
```
## nfs
The client runs one operation against an NFSv4.2 server and works against
any server that speaks the minor version, `nfsd` included. Paths address the
server's namespace from its root.
| Command | Purpose |
|---|---|
| `version` | print the version |
| `ls [path]` | list a directory, the root by default; one line per entry: name, size, octal mode |
| `cat path` | stream a file to standard output |
| `put local remote` | write a local file to the server; creates with mode 0644 and truncates first, so a shorter file leaves no tail |
| `get remote local` | copy a remote file into a local file; the local file is truncated first, so a shorter remote leaves no tail |
| `rm path` | remove one object from the server |
| `mkdir path` | make one directory on the server |
| `stat path` | print the type, size, octal mode and modification time of an object |
| `selftest` | run the whole operation matrix against the server: mkdir, touch, write and compare 64 KiB, list, rename, symlink, a nested directory, ownership of files created as another uid, setattr, and the removals; one line per check plus a summary, exit 1 when any check fails. The work directory is removed on success and left in place on failure |
| Flag | Default | Effect |
|---|---|---|
| `-addr` | `127.0.0.1:2049` | the server address |
| `-concurrency` | `1` | compounds in flight for `get` and `put`, 1 to 8; more slots move several chunks at once over the one connection |
```sh
$ ./bin/nfs -addr 127.0.0.1:2049 ls
hello.txt 5 644
$ ./bin/nfs -addr 127.0.0.1:2049 cat /hello.txt
ahoj
```
## Exit codes
| Code | nfsd | nfs |
|---|---|---|
| `0` | clean shutdown on SIGINT or SIGTERM, or the version print | the operation completed |
| `1` | no export, a bad export path, a listen failure or a listener failure | the dial, the session or the operation failed |
| `2` | | wrong arguments: no, unknown or starved subcommand |
## Examples
Serve a tree and read it from another terminal:
```sh
mkdir -p /srv/demo && echo "ahoj" > /srv/demo/hello.txt
./bin/nfsd -export /srv/demo -addr 127.0.0.1:2049
```
```sh
./bin/nfs -addr 127.0.0.1:2049 ls
./bin/nfs -addr 127.0.0.1:2049 cat /hello.txt
./bin/nfs -addr 127.0.0.1:2049 put README.md /readme.md
./bin/nfs -addr 127.0.0.1:2049 stat /readme.md
```
+58
View File
@@ -0,0 +1,58 @@
# Configuration
nfsd reads its configuration from the file the `-config` flag names, in TOML.
Without `-config` no file is read and every setting comes from the flags and
the built-in defaults; the file is never looked for in a default location.
## File
A complete example with every key present:
```toml
listen = ":2049"
log-ops = false
state-dir = ""
max-connections = 0
[tls]
cert = ""
key = ""
[[export]]
path = "/srv/demo"
read-only = false
root-squash = false
```
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `listen` | string | `":2049"` | the TCP address to listen on, the `-addr` flag |
| `log-ops` | boolean | `false` | log every operation to stderr, the `-log-ops` flag |
| `state-dir` | string | `""` | the directory for persisted handles and opens, the `-state-dir` flag; empty means nothing persists |
| `max-connections` | integer | `0` | the cap on live connections, the `-max-connections` flag; `0` means no cap |
| `tls.cert` | string | `""` | the certificate chain in PEM for RPC-with-TLS (RFC 9289), the `-tls-cert` flag |
| `tls.key` | string | `""` | the private key in PEM for RPC-with-TLS, the `-tls-key` flag |
| `export.path` | string | | the directory to serve; required, the `-export` flag |
| `export.read-only` | boolean | `false` | serve the export read only, the `-ro` flag |
| `export.root-squash` | boolean | `false` | map a client claiming uid 0 onto nobody (65534), the `-root-squash` flag; the default keeps the trust AUTH_SYS gives to the claim, and operators serving untrusted clients are advised to turn it on |
The `[[export]]` array carries exactly one table: this server serves one
export. A future release that serves several exports lifts the count without
changing the schema.
## Precedence
The command line flags win, then the file, then the built-in defaults. A flag
present on the command line overrides the file even when it carries the
default value, so `-ro=false` keeps a `read-only = true` from the file at
`false`. A key the file leaves out yields to the flag default.
## Validation
A file that fails is a failed start up. A syntax error is reported with the
file and the line: `nfsd: /etc/nfsd/nfsd.toml:2: expected '=' after key`. A
key the schema does not carry is rejected, so a typo never slips through as
an ignored setting. A file without exactly one `[[export]]`, or one without
`path`, ends the start up with a message naming the file and the count.
+168
View File
@@ -0,0 +1,168 @@
# Deployment
How nfsd runs in production.
## Topology
```mermaid
flowchart LR
C1[NFS client] --> S[nfsd]
C2[NFS client] --> S
S --> Disk[(export tree)]
S --> State[(state dir)]
```
One nfsd process serves one exported directory tree to any number of NFSv4.2
clients over the single TCP port 2049. There is no portmapper, no mountd and
no separate locking protocol: a client mounts `nfs://host:2049/` directly and
everything rides the one connection or its successors.
## Requirements
- a Linux host, on amd64, arm64, loong64 or riscv64; the binary is static, no
runtime libraries
- port 2049 free; it is not a privileged port, so the service does not need
root
- the exported directory must exist before start; the service identity needs
read access to it, and write access where clients may write
- a writable state directory, when persistence is on, writable by the service
identity alone (mode 0700)
- write access to the export tree requires one of: the service runs as root,
or the service holds `CAP_CHOWN` and `CAP_MKNOD`, or the operator accepts
the identity behaviour described under Privileges
## Build
```sh
just build
```
The binaries land in `bin/nfsd` and `bin/nfs`.
## Run
```sh
bin/nfsd -config /etc/nfsd/nfsd.toml
```
The configuration file is described in [CONFIGURATION.md](CONFIGURATION.md);
it carries the listen address, the export, the state directory, the
connection cap, the TLS key pair and the root squash switch. The flags
override the file.
## Service unit
```ini
[Unit]
Description=NFSv4.2 server for one export
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=nfsd
Group=nfsd
ExecStart=/usr/local/bin/nfsd -config /etc/nfsd/nfsd.toml
StateDirectory=nfsd
AmbientCapabilities=CAP_CHOWN CAP_MKNOD
CapabilityBoundingSet=CAP_CHOWN CAP_MKNOD
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/srv/export
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
`Type=notify` is real readiness: the server writes `READY=1` to
`$NOTIFY_SOCKET` once the listener is up, and systemd considers the unit
started at that moment, not at the fork. `StateDirectory=nfsd` creates
`/var/lib/nfsd` owned by the service identity; point the configuration's
`state-dir` at it and handles and opens survive a restart inside the grace
window.
### Privileges
The port needs no privilege, so the unit runs under a dedicated identity and
names exactly two capabilities:
- `CAP_CHOWN` lets the server hand a freshly created object to the identity
the client presented. Without it the object keeps the service identity;
the server still answers the client's claim as the owner attribute, but
the on disk owner is the service one. This is a deliberate operator
decision, documented here and not hidden: serving untrusted clients
without `CAP_CHOWN` changes whose identity new files carry on disk.
- `CAP_MKNOD` serves the special objects a CREATE may carry, character and
block devices among them. Without it those creations fail.
Neither capability lets the service read a file it could not already reach.
### Root squash
Serve untrusted clients with `root-squash = true` in the export: a client
claiming uid 0 acts as nobody (65534), loses the superuser grant, and its
objects carry nobody. The default is `false`, which keeps the trust AUTH_SYS
hands to the claim; an operator who controls every client may keep it.
## Production configuration
```toml
listen = ":2049"
state-dir = "/var/lib/nfsd"
max-connections = 256
log-ops = false
[tls]
cert = "/etc/nfsd/cert.pem"
key = "/etc/nfsd/key.pem"
[[export]]
path = "/srv/export"
root-squash = true
```
The TLS key pair enables RPC-with-TLS of RFC 9289; a client that skips the
upgrade is refused. The certificate comes from the operator's PKI; no
credential belongs in this repository or its configuration examples.
## Firewall
One port in, no outbound requirement beyond what the clients reach the back
channel on: the server calls the client back on the client's connection, so
no inbound port per client is needed.
```sh
firewall-cmd --permanent --add-port=2049/tcp && firewall-cmd --reload
```
## Upgrade
```sh
just build
install -m 755 bin/nfsd /usr/local/bin/nfsd
systemctl restart nfsd
```
With `state-dir` set the restart is a recovery, not a loss: the new process
loads the handle map and the opens, and clients inside the grace window
reclaim with CLAIM_PREVIOUS. Without it, clients re establish their sessions
and re open; their mounted trees keep working through the file handles the
backend re registers.
## Rollback
Reinstall the previous binary and restart; the state directory format has
one version so far, so a downgrade reloads the same state. Not rehearsed
against a released predecessor yet: rehearse before relying on it.
## Monitoring
- the unit's ready state: `systemctl is-active nfsd`
- the operation log, when `log-ops` is on: every operation, its status and
its duration on standard error, journald's `journalctl -u nfsd` picks it up
- the connection cap answering refusals shows up as clients reconnecting;
a steady refusal rate means the cap or the client count is wrong
- a healthy idle server logs nothing and holds no CPU: check
`systemctl status nfsd` for a flat memory figure and `ss -tnp sport = 2049`
for the connected clients
+90
View File
@@ -0,0 +1,90 @@
# Development
How to work on nfs.
## Prerequisites
- Go 1.27.1, the newest stable release.
- [just](https://github.com/casey/just) for the recipes.
- gcc, for the race detector in `just gates`.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/nfs.git
cd nfs
just build
```
## Recipes
Every recipe in the project's file, and what it does. Taken from the file itself, so
the names and the list match it exactly.
| Recipe | What it does |
|---|---|
| `just gates` | the definition of done: build, format check, vet, modernisation, the test suite with the coverage floor, and the race detector |
| `just build` | compiles `cmd/nfsd` and `cmd/nfs` into `bin/nfsd` and `bin/nfs`, zero errors and zero warnings |
| `just test` | the full suite with no test cache and the 80 percent coverage floor |
| `just race` | the same suite under the race detector |
| `just unit ./internal/xdr 'TestName'` | a fast scoped run for iterating |
| `just fuzz FuzzXdr ./internal/xdr 60s` | a time boxed fuzz of one target in one package |
| `just bench` | the benchmarks, five counts, allocation stats on |
| `just fmt` | gofmt over the tree, in place |
| `just fmt-check` | zero diff, prints nothing when everything is formatted |
| `just vet` | `go vet` and `go fix -diff` |
| `just clean` | removes `bin/` and `coverage.out` |
| `just install` | builds, then copies `bin/nfsd` and `bin/nfs` into the user's bin directory |
| `just uninstall` | removes the installed binary |
| `just run` | runs the program in place; nfsd exits at once until it is given an export, so a real run passes flags to the built binary: `./bin/nfsd -export DIR` |
| `just dev` | the same as `run`, for now |
The test and bench recipes sweep `./internal/...`, and not
the whole tree: the thin `cmd/nfsd` and `cmd/nfs` count as zero coverage and
would drag the floor below 80 percent on their own. The protocol logic lives
under `internal/`.
## Running a single test
```sh
go test -run TestName ./package
```
Add `-v` for the sub-test names, and `-race` when the change touches concurrency.
`-count=1` defeats the test cache when a result looks stale.
## Coverage
```sh
just test
go tool cover -func=coverage.out
```
The `total:` line is the number that matters, and it stays at 80 percent or more.
## Benchmarks
```sh
just bench
```
Benchmark on an idle machine, and compare only runs made in one process against each other.
## Debugging the build
```sh
go build -gcflags='-m' ./... # inlining decisions
go build -gcflags='-S' ./... # what the compiler generated
```
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners. They are
written by hand rather than through `just`, but they enforce the same set of gates, so a
green `just gates` locally is the fastest way to a green pipeline.
## Releases
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The tag
drives the release workflow, which builds the assets and publishes the notes it
extracted from `CHANGELOG.md`.
@@ -0,0 +1,31 @@
# Measurement: the descriptor cache in internal/nfsfs
- Date: 2026-09-22
- Machine: AMD Ryzen AI Max+ Pro 395 (32 threads), idle
- Toolchain: go1.27.1, no build flags
- Command: `go test ./internal/nfsfs/ -run '^$' -bench=. -benchmem -count=5`
## Baseline
The commit b8758b7, the head of development before the descriptor cache: every
READ, WRITE and SYNC opened the registered path, verified it with a stat and
closed the descriptor again, per operation. The cache keeps idle descriptors of
regular files in a bounded LRU and revalidates the identity on every use, so the
measurements answer one question: what the open and close per operation cost.
## Result
Median of five runs, same session, same machine.
| Benchmark | Baseline | With cache | Change |
|---|---|---|---|
| `BenchmarkRead64K` | 10888 ns/op | 9047 ns/op | -16.9 % |
| `BenchmarkWrite64K` | 5881 ns/op | 5236 ns/op | -11.0 % |
| `BenchmarkGetattr` | 461.4 ns/op | 437.2 ns/op | -5.2 % |
| `BenchmarkLookup` | 1216 ns/op | 1134 ns/op | -6.7 % |
The two data operations are the ones the cache touches, and they gain 11 to 17
percent per operation. GETATTR and LOOKUP run the same code as before the cache
(they resolve paths through Lstat either way), so their shifts are code layout
noise of the same binary, not an effect to claim; both sit within the run to run
spread the five repetitions showed.
+27
View File
@@ -0,0 +1,27 @@
# Measurement: the paginated READDIR
- Date: 2026-09-22
- Machine: AMD Ryzen AI Max+ Pro 395 (32 threads), otherwise idle
- Toolchain: go1.27.1, no build flags
- Command: `go test ./internal/nfsfs/ -run '^$' -bench=ReadDirPage -benchmem -count=1 -benchtime=50x`
## Baseline
The commit 340783e, measured in the same session as the new code. The
benchmark pages a 10 000 entry directory 64 entries at a time, which is the
shape of a client listing a large directory through READDIR pages: the
baseline re listed and re sorted the whole directory for every page.
## Result
Median free, 50 iterations per side, one session.
| Benchmark | Baseline | With the listing cache | Change |
|---|---|---|---|
| `BenchmarkReadDirPage64` | 1863611 ns/op, 1402 KiB/op, 20290 allocs/op | 62506 ns/op, 40 KiB/op, 267 allocs/op | -96.6 % |
A page of 64 costs 30 times less once the sorted order is cached and
revalidated against the directory's modification time, and the cost no
longer grows with the size of the directory: the numbers above are the
boundary case, where a page paid for listing and sorting ten thousand names
to serve sixty four of them.
+39
View File
@@ -0,0 +1,39 @@
# Measurement: the request path without waste
- Date: 2026-09-22
- Machine: AMD Ryzen AI Max+ Pro 395 (32 threads), otherwise idle
- Toolchain: go1.27.1, no build flags
- Command: `go test ./internal/nfs4server/ -run '^$' -bench=Wire -benchmem -count=5`
## Baseline
The commit c50cd0b, measured in the same session as the new code, both sides
five runs back to back. The baseline is the wire baseline of
[2026-09-22-wire-baseline.md](2026-09-22-wire-baseline.md) plus the client
file commands.
## Result
Median of five runs per side, one session.
| Benchmark | Latency old | Latency new | Change | Allocs old | Allocs new | Change |
|---|---|---|---|---|---|---|
| `BenchmarkWireRead64K` | 92.0 µs | 81.5 µs | -11.4 % | 78 | 63 | -19 % |
| `BenchmarkWireWrite64K` | 61.4 µs | 72.2 µs | see note | 76 | 62 | -18 % |
| `BenchmarkWireGetattr` | 14.2 µs | 13.5 µs | -4.5 % | 85 | 70 | -18 % |
| `BenchmarkWireLookup` | 15.2 µs | 15.3 µs | 0 % | 81 | 66 | -19 % |
Bytes per operation fell 27 percent on READ and 13 percent on WRITE. The
changes behind the numbers: the COMPOUND answer accumulates in one buffer
with the header patched in place instead of copying every operation result
twice, READ fills the reply buffer through the backend's `ReadInto` instead
of an intermediate allocation and drops the second attribute call for the
end of file flag, WRITE hands the request record's own bytes to the backend
instead of copying them out, and the outgoing record marking buffers recycle
through a bounded pool.
The WRITE latency column carries an honest warning: the machine's session to
session variance on this benchmark exceeds the effect being measured. Within
a single session the order of the two sides flipped twice; the deterministic
counters, allocations and bytes, are the trustworthy part of the WRITE row,
and the READ row's improvement is well outside the noise.
+30
View File
@@ -0,0 +1,30 @@
# Measurement: the wire level baseline
- Date: 2026-09-22
- Machine: AMD Ryzen AI Max+ Pro 395 (32 threads), idle
- Toolchain: go1.27.1, no build flags
- Command: `go test ./internal/nfs4server/ -run '^$' -bench=Wire -benchmem -count=5`
## Baseline
The commit 354760b, the head of development: the wire benchmarks are new, so this
report is the baseline every later optimisation of the request path measures
against. One benchmark iteration is one COMPOUND of the client library against
the server handler over a loopback connection, sessions included.
## Result
Median of five runs, same session, same machine.
| Benchmark | Throughput | Latency | Allocations |
|---|---|---|---|
| `BenchmarkWireRead64K` | 805 MB/s | 81.4 µs/op | 78 allocs, 628 KiB/op |
| `BenchmarkWireWrite64K` | 1062 MB/s | 61.7 µs/op | 76 allocs, 428 KiB/op |
| `BenchmarkWireGetattr` | - | 14.2 µs/op | 85 allocs, 4.1 KiB/op |
| `BenchmarkWireLookup` | - | 15.0 µs/op | 81 allocs, 4.4 KiB/op |
READ of a 64 KiB chunk is slower than WRITE of the same chunk, and the
allocation columns show where the request path spends its memory: around 80
allocations per COMPOUND regardless of the operation, on top of the data copies
the read and write paths make. Both facts are the starting point for the
optimisations of the request path; neither is a claim about any other setup.
+5
View File
@@ -0,0 +1,5 @@
module sourcedock.dev/petrbalvin/nfs
go 1.27.1
require sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
+2
View File
@@ -0,0 +1,2 @@
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0 h1:DkWtszKv4BTafedilEKY5QuTaOSv/J8gpcxHoi6oHnw=
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0/go.mod h1:SCMhffAzwoPrmeHKHeAar7dKm58QKKMdL8qhaZmc4ds=
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package krb5
import (
"bytes"
"testing"
)
// The AP-REP the acceptor answers with completes the client half of the
// context: it verifies under the session key, and nothing else does.
func TestClientAcceptRepRoundTrip(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
client, token, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "client")
if err != nil {
t.Fatal(err)
}
acceptor, aprep, err := AcceptInit(token, key)
if err != nil {
t.Fatal(err)
}
_ = acceptor
if err := client.ClientAcceptRep(aprep); err != nil {
t.Fatalf("accept rep: %v", err)
}
// The shared key makes both halves sign tokens the other verifies.
mic, err := client.GetMIC([]byte("data"))
if err != nil {
t.Fatal(err)
}
if err := acceptor.VerifyMIC([]byte("data"), mic); err != nil {
t.Fatalf("cross verify: %v", err)
}
// Anything but the genuine AP-REP is refused: a wrong tag, a wrong
// message type, a body from another key.
if err := client.ClientAcceptRep([]byte{0x6e, 0x00}); err == nil {
t.Fatal("a two byte token accepted")
}
tampered := append([]byte{}, aprep...)
tampered[len(tampered)-1] ^= 1
if err := client.ClientAcceptRep(tampered); err == nil {
t.Fatal("a tampered AP-REP accepted")
}
other, otoken, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "other")
if err != nil {
t.Fatal(err)
}
if err := other.ClientAcceptRep(aprep); err == nil {
t.Fatal("an AP-REP of another context accepted")
}
if bytes.Equal(otoken, token) {
t.Fatal("two inits minted the same token")
}
}
+270
View File
@@ -0,0 +1,270 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The AES encryption profiles of RFC 3962 over the simplified profile
// of RFC 3961: key derivation DK, the CBC-CTS cipher, and the keyed
// checksum, with the key usage constants of RFC 4120 and RFC 4121.
package krb5
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/binary"
"errors"
"hash"
)
// Encryption types of RFC 3962.
const (
EtypeAES128 = 17
EtypeAES256 = 18
)
// Key usage numbers of RFC 4120 section 7.5.1 and RFC 4121 section 4.
const (
UsageTicket = 2
UsageInitiatorSign = 7
UsageAcceptorSign = 8
UsageInitiatorSeal = 9
UsageAcceptorSeal = 10
UsageAPReqAuth = 11
UsageAPRepAuth = 12
)
// ErrIntegrity marks a checksum mismatch on decryption.
var ErrIntegrity = errors.New("krb5: integrity check failed")
// deriveKey implements DK of RFC 3961 section 5.1: the constant is
// n-folded to the cipher block size, encrypted with the base key until
// enough bits accumulate, and the first keySize bytes are the key.
func deriveKey(key []byte, constant []byte, keyBytes int) []byte {
block := 16
folded := NFold(constant, block)
var out []byte
prev := folded
for len(out) < keyBytes {
prev = aesECB(key, prev)
out = append(out, prev...)
}
return out[:keyBytes]
}
// DK derives the protocol key for a usage: the constant is the usage
// number as four big endian octets followed by the label octet.
func DK(key []byte, usage uint32, label byte) []byte {
var constant [5]byte
binary.BigEndian.PutUint32(constant[:4], usage)
constant[4] = label
return deriveKey(key, constant[:], len(key))
}
// deriveKeys answers the three keys of the simplified profile:
// Kc for checksums, Ke for encryption, Ki for the message HMAC.
func deriveKeys(key []byte, usage uint32) (kc, ke, ki []byte) {
return DK(key, usage, 0x99), DK(key, usage, 0xAA), DK(key, usage, 0x55)
}
// aesECB encrypts a block aligned input in ECB mode, which is how the
// profile uses the cipher for key derivation.
func aesECB(key, block []byte) []byte {
c, err := aes.NewCipher(key)
if err != nil {
panic("krb5: " + err.Error())
}
out := make([]byte, len(block))
for i := 0; i < len(block); i += c.BlockSize() {
c.Encrypt(out[i:], block[i:])
}
return out
}
// ctsEncrypt implements the Kerberos CBC-CTS mode of RFC 3962: plain
// CBC for everything but the tail, with the last two blocks through
// ciphertext stealing. The output carries the input length, except an
// ctsEncrypt implements the Kerberos CBC-CTS mode of RFC 3962, ported
// from the MIT krb5 reference: plain CBC over everything but the last
// two blocks, whose ciphertexts swap places, with the final block
// truncated back to the input length.
func ctsEncrypt(key []byte, plaintext []byte) []byte {
c, err := aes.NewCipher(key)
if err != nil {
panic("krb5: " + err.Error())
}
n := len(plaintext)
if n == 0 {
panic("krb5: empty plaintext")
}
blocks := (n + 15) / 16
lastLen := n - (blocks-1)*16
if blocks == 1 {
block := make([]byte, 16)
copy(block, plaintext)
out := make([]byte, 16)
c.Encrypt(out, block)
return out
}
buf := make([]byte, blocks*16)
copy(buf, plaintext)
head := buf[:(blocks-2)*16]
iv := make([]byte, 16)
cipher.NewCBCEncrypter(c, iv).CryptBlocks(head, head)
prev := make([]byte, 16)
if blocks > 2 {
copy(prev, head[len(head)-16:])
}
p1 := buf[(blocks-2)*16 : (blocks-1)*16]
p2 := buf[(blocks-1)*16:]
c1 := make([]byte, 16)
for i := range c1 {
c1[i] = p1[i] ^ prev[i]
}
c.Encrypt(c1, c1)
c2 := make([]byte, 16)
for i := range c2 {
c2[i] = p2[i] ^ c1[i]
}
c.Encrypt(c2, c2)
out := make([]byte, 0, n)
out = append(out, head...)
out = append(out, c2...)
out = append(out, c1[:lastLen]...)
return out
}
// ctsDecrypt reverses ctsEncrypt.
func ctsDecrypt(key []byte, ciphertext []byte) []byte {
c, err := aes.NewCipher(key)
if err != nil {
panic("krb5: " + err.Error())
}
n := len(ciphertext)
blocks := (n + 15) / 16
lastLen := n - (blocks-1)*16
if blocks == 1 {
block := make([]byte, 16)
copy(block, ciphertext)
out := make([]byte, 16)
c.Decrypt(out, block)
return out
}
buf := make([]byte, blocks*16)
copy(buf, ciphertext)
head := buf[:(blocks-2)*16]
prev := make([]byte, 16)
if blocks > 2 {
copy(prev, head[len(head)-16:])
}
if len(head) > 0 {
cipher.NewCBCDecrypter(c, make([]byte, 16)).CryptBlocks(head, head)
}
blockN2 := append([]byte{}, buf[(blocks-2)*16:(blocks-1)*16]...)
blockN1 := append([]byte{}, buf[(blocks-1)*16:]...)
blockN1 = append(blockN1, make([]byte, 16-len(blockN1))...)
// The final plaintext block decrypts with the truncated ciphertext
// block as the CBC vector.
p2 := make([]byte, 16)
c.Decrypt(p2, blockN2)
for i := range p2 {
p2[i] ^= blockN1[i]
}
// The stolen tail of the decrypted final block restores the truncated
// second-to-last ciphertext block.
for i := lastLen; i < 16; i++ {
blockN1[i] = p2[i]
}
p1 := make([]byte, 16)
c.Decrypt(p1, blockN1)
for i := range p1 {
p1[i] ^= prev[i]
}
out := make([]byte, 0, n)
out = append(out, head...)
out = append(out, p1...)
out = append(out, p2[:lastLen]...)
return out
}
// Encrypt applies the RFC 3962 simplified profile over AES: a random
// confounder, the plaintext, and a truncated HMAC under Ki, the whole
// confounder and plaintext encrypted under Ke by CBC-CTS.
func Encrypt(etype uint32, key []byte, usage uint32, plaintext []byte) ([]byte, error) {
_, ke, ki := deriveKeys(key, usage)
conf := make([]byte, 16)
if _, err := rand.Read(conf); err != nil {
return nil, err
}
data := append(append([]byte{}, conf...), plaintext...)
body := ctsEncrypt(ke, data)
mac := hmac.New(sha1.New, ki)
mac.Write(data)
return append(body, mac.Sum(nil)[:12]...), nil
}
// Decrypt verifies and removes the protection Encrypt applied.
func Decrypt(etype uint32, key []byte, usage uint32, ciphertext []byte) ([]byte, error) {
_, ke, ki := deriveKeys(key, usage)
if len(ciphertext) < 16+12 {
return nil, ErrIntegrity
}
body := ciphertext[:len(ciphertext)-12]
mac := ciphertext[len(ciphertext)-12:]
data := ctsDecrypt(ke, body)
expect := hmac.New(sha1.New, ki)
expect.Write(data)
if !hmac.Equal(expect.Sum(nil)[:12], mac) {
return nil, ErrIntegrity
}
return data[16:], nil
}
// Checksum answers the keyed checksum of RFC 3961 section 5.4: the
// leading twelve bytes of HMAC-SHA1 under Kc.
func Checksum(etype uint32, key []byte, usage uint32, message []byte) ([]byte, error) {
kc, _, _ := deriveKeys(key, usage)
mac := hmac.New(sha1.New, kc)
mac.Write(message)
return mac.Sum(nil)[:12], nil
}
// StringToKey implements the PBKDF2+DK function of RFC 3962 section 4:
// PBKDF2 over HMAC-SHA1 with the given iteration count, then one
// application of the key derivation with the well known constant
// "kerberos" of the AES profile.
func StringToKey(etype uint32, password, salt []byte, iterations uint32, keyBytes int) []byte {
_ = etype
seed := pbkdf2(sha1.New, password, salt, int(iterations), keyBytes)
pepper := []byte("kerberos")
return deriveKey(seed, pepper, keyBytes)
}
// pbkdf2 is the PBKDF2 of RFC 2898 over the given hash.
func pbkdf2(h func() hash.Hash, password, salt []byte, rounds, length int) []byte {
out := make([]byte, 0, length)
var block [4]byte
for blockIndex := 1; len(out) < length; blockIndex++ {
block[0] = byte(blockIndex >> 24)
block[1] = byte(blockIndex >> 16)
block[2] = byte(blockIndex >> 8)
block[3] = byte(blockIndex)
mac := hmac.New(sha1.New, password)
mac.Write(salt)
mac.Write(block[:])
u := mac.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < rounds; i++ {
mac.Reset()
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
out = append(out, t...)
}
return out[:length]
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package krb5
import (
"bytes"
"encoding/hex"
"testing"
)
func unhex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
// The n-fold test vectors of RFC 3961 appendix A.
func TestNFoldVectors(t *testing.T) {
cases := []struct {
in string
bits int
want string
}{
{"303132333435", 64, "be072631276b1955"},
{"70617373776f7264", 56, "78a07b6caf85fa"},
{"526f75676820436f6e73656e7375732c20616e642052756e" +
"6e696e6720436f6465", 64, "bb6ed30870b7f0e0"},
{"70617373776f7264", 168, "59e4a8ca7c0385c3c37b3f6d2000247cb6e6bd5b3e"},
{"4d41535341434856534554545320494e5354495456544520" +
"4f4620544543484e4f4c4f4759", 192,
"db3b0d8f0b061e603282b308a50841229ad798fab9540c1b"},
{"51", 168, "518a54a215a8452a518a54a215a8452a518a54a215"},
{"6261", 168, "fb25d531ae8974499f52fd92ea9857c4ba24cf297e"},
}
for _, c := range cases {
got := NFold(unhex(t, c.in), c.bits/8)
if !bytes.Equal(got, unhex(t, c.want)) {
t.Errorf("nfold %d bits of %s: % x, want %s", c.bits, c.in, got, c.want)
}
}
}
// The key derivation test values of the MIT krb5 reference suite: the
// AES-128 key with the checksum and encryption constants of usage two.
func TestDeriveVector(t *testing.T) {
key := unhex(t, "42263c6e89f4fc28b8df68ee09799f15")
kc := DK(key, 2, 0x99)
if !bytes.Equal(kc, unhex(t, "34280a382bc92769b2da2f9ef066854b")) {
t.Fatalf("Kc % x", kc)
}
}
// The PBKDF2 string-to-key vectors of RFC 3962 appendix B, and the
// resulting protocol keys.
func TestStringToKeyVectors(t *testing.T) {
salt := []byte("ATHENA.MIT.EDUraeburn")
k128 := StringToKey(EtypeAES128, []byte("password"), salt, 1, 16)
if !bytes.Equal(k128, unhex(t, "42263c6e89f4fc28b8df68ee09799f15")) {
t.Fatalf("aes128 key % x", k128)
}
k256 := StringToKey(EtypeAES256, []byte("password"), salt, 1, 32)
if !bytes.Equal(k256, unhex(t, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161")) {
t.Fatalf("aes256 key % x", k256)
}
k2 := StringToKey(EtypeAES256, []byte("password"), salt, 2, 32)
if !bytes.Equal(k2, unhex(t, "a2e16d16b36069c135d5e9d2e25f896102685618b95914b467c67622225824ff")) {
t.Fatalf("aes256 two rounds % x", k2)
}
}
// The checksum test value of the MIT krb5 reference suite: HMAC-SHA1-96
// under the derived checksum key of usage three.
func TestChecksumVector(t *testing.T) {
key := unhex(t, "9062430c8cda3388922e6d6a509f5b7a")
sum, err := Checksum(EtypeAES128, key, 3, []byte("eight nine ten eleven twelve thirteen"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(sum, unhex(t, "01a4b088d45628f6946614e3")) {
t.Fatalf("checksum % x", sum)
}
}
// The profile round trips at both key sizes and over lengths that walk
// the CTS edge cases.
func TestEncryptRoundTrip(t *testing.T) {
key := unhex(t, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161")
for _, size := range []int{0, 1, 15, 16, 17, 31, 32, 33, 100, 1000} {
plain := make([]byte, size)
for i := range plain {
plain[i] = byte(i)
}
ct, err := Encrypt(EtypeAES256, key, UsageInitiatorSeal, plain)
if err != nil {
t.Fatalf("size %d: %v", size, err)
}
got, err := Decrypt(EtypeAES256, key, UsageInitiatorSeal, ct)
if err != nil {
t.Fatalf("size %d: %v", size, err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("size %d: round trip differs", size)
}
// One flipped byte must break the integrity check.
ct[len(ct)/2] ^= 0xff
if _, err := Decrypt(EtypeAES256, key, UsageInitiatorSeal, ct); err == nil {
t.Fatalf("size %d: tampering passed", size)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// A minimal DER encoder and decoder for the Kerberos protocol messages
// of RFC 4120: just the constructs the AP-REQ and AP-REP exchanges of
// the GSS context establishment need.
package krb5
import (
"encoding/binary"
"errors"
)
// DER tag octets used below.
const (
tagInteger = 0x02
tagBitString = 0x03
tagOctet = 0x04
tagNull = 0x05
tagOID = 0x06
tagSequence = 0x30
tagGeneral = 0x80 // the high bits of a context or application tag
)
// ErrDER marks a malformed DER input.
var ErrDER = errors.New("krb5: malformed DER")
// derLen encodes a DER length octet string.
func derLen(n int) []byte {
if n < 0x80 {
return []byte{byte(n)}
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte(n)
n >>= 8
}
return append([]byte{0x80 | byte(len(buf)-i)}, buf[i:]...)
}
// derTLV wraps a payload in one tag-length-value record.
func derTLV(tag byte, payload []byte) []byte {
return append(append([]byte{tag}, derLen(len(payload))...), payload...)
}
// derInt encodes a DER integer.
func derInt(v uint64) []byte {
var raw [8]byte
binary.BigEndian.PutUint64(raw[:], v)
i := 0
for i < 7 && raw[i] == 0 && raw[i+1]&0x80 == 0 {
i++
}
for i < 8 && raw[i] == 0xff && i+1 < 8 && raw[i+1]&0x80 != 0 {
i++
}
return derTLV(tagInteger, raw[i:])
}
// derGeneralString encodes a Kerberos string.
func derGeneralString(s string) []byte {
return derTLV(0x1b, []byte(s))
}
// derOID encodes the krb5 mechanism object identifier.
var derOID = []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02}
// derReader walks a DER structure.
type derReader struct {
b []byte
off int
}
func newDERReader(b []byte) *derReader { return &derReader{b: b} }
// next reads one TLV and answers the tag and payload.
func (r *derReader) next() (byte, []byte, error) {
if r.off+2 > len(r.b) {
return 0, nil, ErrDER
}
tag := r.b[r.off]
length := int(r.b[r.off+1])
start := r.off + 2
if length&0x80 != 0 {
n := length & 0x7f
if n == 0 || n > 4 || start+n > len(r.b) {
return 0, nil, ErrDER
}
length = 0
for i := range n {
length = length<<8 | int(r.b[start+i])
}
start += n
}
if start+length > len(r.b) {
return 0, nil, ErrDER
}
r.off = start + length
return tag, r.b[start : start+length], nil
}
// expect reads one TLV and requires the tag.
func (r *derReader) expect(tag byte) ([]byte, error) {
got, payload, err := r.next()
if err != nil {
return nil, err
}
if got != tag {
return nil, ErrDER
}
return payload, nil
}
// derUint decodes a DER integer payload.
func derUint(payload []byte) (uint64, error) {
if len(payload) == 0 || len(payload) > 8 {
return 0, ErrDER
}
var v uint64
for _, b := range payload {
v = v<<8 | uint64(b)
}
return v, nil
}
// derBitString decodes a DER bit string payload: the leading unused bit
// count octet followed by the bits.
func derBitString(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, ErrDER
}
return payload[1:], nil
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package krb5
import (
"testing"
)
// FuzzAcceptInit feeds arbitrary context establishment tokens through
// the acceptor: no input may panic the DER walk or the crypto, and a
// forged token must fail closed.
func FuzzAcceptInit(f *testing.F) {
key := make([]byte, 32)
_, token, err := ClientInit(EtypeAES256, key, "EXAMPLE.ORG", "nfs", "probe")
if err != nil {
f.Fatal(err)
}
f.Add(token)
f.Add([]byte{0x6e, 0x00})
f.Add([]byte{0x6e, 0x20, 0x30, 0x1d, 0x02})
f.Add(make([]byte, 32))
f.Fuzz(func(t *testing.T, data []byte) {
// The property under test is that the acceptor never panics;
// anything but a genuine token is an error.
_, _, _ = AcceptInit(data, key)
_ = (&Context{Etype: EtypeAES256, Key: key}).ClientAcceptRep(data)
})
}
+710
View File
@@ -0,0 +1,710 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The Kerberos V5 GSS-API mechanism of RFC 4121 over the raw krb5
// mechanism of RFC 1964 framing: the context establishment through
// AP-REQ and AP-REP (RFC 4120), and the per-message MIC and Wrap
// tokens (RFC 4121 sections 4.2.4 to 4.2.6).
package krb5
import (
"crypto/hmac"
"crypto/rand"
"encoding/binary"
"errors"
"strings"
"sync"
"time"
)
// GSS token identifiers and flag bits of RFC 4121.
const (
TokenMIC = 0x0404
TokenWrap = 0x0504
FlagSentByAcceptor = 0x01
FlagSealed = 0x02
FlagAcceptorSubkey = 0x04
)
// clockSkew is the window the acceptor tolerates between the client
// clock and its own, five minutes, the customary maximum of RFC 4120
// section 10.1.
const clockSkew = 5 * time.Minute
// ErrToken marks a malformed or unusable GSS token.
var ErrToken = errors.New("krb5: bad gss token")
// ErrContext marks a mismatch against the established context.
var ErrContext = errors.New("krb5: context mismatch")
// ErrReplay marks an authenticator the acceptor has already seen.
var ErrReplay = errors.New("krb5: replayed token")
// ErrExpired marks a ticket whose endtime has passed.
var ErrExpired = errors.New("krb5: ticket expired")
// ErrSkew marks an authenticator outside the clock skew window.
var ErrSkew = errors.New("krb5: clock skew too great")
// A Context is one established security context: the session key, the
// etype, the per-peer sequence counters and the acceptor role. The
// counters and the token operations are guarded by the internal mutex, so
// one context serves concurrent callers.
type Context struct {
Key []byte
Etype uint32
Accepting bool
SendSeq uint64
RecvSeq uint64
mu sync.Mutex
}
// appTag wraps a payload in the DER application tag of RFC 4120.
func appTag(number byte, payload []byte) []byte {
return derTLV(0x60|number, payload)
}
// principalName encodes a PrincipalName of name type one, the name of
// a single component service.
func principalName(name string) []byte {
names := derTLV(tagSequence, derGeneralString(name))
return derTLV(tagSequence, append(derInt(1), names...))
}
// encryptedData encodes an EncryptedData with a version number.
func encryptedData(etype uint32, cipher []byte, kvno uint64) []byte {
body := append(derInt(uint64(etype)), derInt(kvno)...)
body = append(body, derTLV(tagOctet, cipher)...)
return derTLV(tagSequence, body)
}
// checksum encodes a Checksum of type 0x8003, the GSS-API checksum.
func gssChecksum(sum []byte) []byte {
return derTLV(tagSequence, append(derInt(0x8003), derTLV(tagOctet, sum)...))
}
// bitFlags encodes the BIT STRING form of the Kerberos flags.
func bitFlags(flags uint32) []byte {
var bits [6]byte
binary.BigEndian.PutUint32(bits[2:], flags)
bits[0] = 5 // construction
bits[1] = 0 // unused bit count; the leading octets of a 32 bit field
bits[2] = 0
bits[3] = byte(flags >> 24)
bits[4] = byte(flags >> 16)
bits[5] = byte(flags >> 8)
bits[5] = byte(flags)
return derTLV(tagBitString, append([]byte{0}, bits[2:6]...))
}
// kerberosTime encodes a GeneralizedTime of whole seconds.
func kerberosTime(t time.Time) []byte {
return derTLV(0x18, []byte(t.UTC().Format("20060102150405Z")))
}
// parseKerberosTime reads the GeneralizedTime of whole seconds.
func parseKerberosTime(payload []byte) (time.Time, error) {
t, err := time.Parse("20060102150405Z", string(payload))
if err != nil {
return time.Time{}, ErrToken
}
return t, nil
}
// buildTicket produces the ticket part of an AP-REQ: the encrypted part
// carries the session key under the service long term key, valid until
// the given endtime.
func buildTicket(realm, service string, session, serviceKey []byte, etype uint32, endtime time.Time) ([]byte, error) {
flags := bitFlags(0)
key := derTLV(tagSequence, append(derInt(uint64(etype)), derTLV(tagOctet, session)...))
inner := append(append(append(append([]byte{}, flags...), key...),
derGeneralString(realm)...), principalName(service)...)
inner = append(inner, derTLV(tagSequence, nil)...) // transited: empty
inner = append(inner, kerberosTime(time.Unix(0, 0))...) // authtime
inner = append(inner, kerberosTime(endtime)...) // endtime
inner = append(inner, derGeneralString(realm)...) // srealm
inner = append(inner, principalName(service)...) // sname
cipher, cerr := Encrypt(etype, serviceKey, UsageTicket, inner)
if cerr != nil {
return nil, cerr
}
ticket := appTag(1, derTLV(tagSequence, append(append(
derInt(5), derGeneralString(realm)...),
append(principalName(service), encryptedData(etype, cipher, 1)...)...)))
return ticket, nil
}
// buildAuthenticator produces the encrypted authenticator of an AP-REQ
// for the given client time, with the 0x8003 checksum of RFC 4121
// section 4.1.1: the little endian length of Bnd, the sixteen Bnd octets
// of an unused channel binding, and the little endian Flags, so a
// conformant acceptor can read it.
func buildAuthenticator(realm, client string, session []byte, etype uint32, seq uint64, ctime time.Time) ([]byte, error) {
inner := append(derInt(5), derGeneralString(realm)...)
inner = append(inner, principalName(client)...)
sum := make([]byte, 0, 24)
var word [4]byte
binary.LittleEndian.PutUint32(word[:], 16)
sum = append(sum, word[:]...) // length of Bnd
sum = append(sum, make([]byte, 16)...) // Bnd: no channel binding
binary.LittleEndian.PutUint32(word[:], 0)
sum = append(sum, word[:]...) // Flags: none
inner = append(inner, gssChecksum(sum)...)
inner = append(inner, derInt(uint64(ctime.Nanosecond())/1000)...)
inner = append(inner, kerberosTime(ctime)...)
inner = append(inner, derInt(seq)...)
cipher, err := Encrypt(etype, session, UsageAPReqAuth, inner)
if err != nil {
return nil, err
}
return encryptedData(etype, cipher, 0), nil
}
// ClientInit builds the context establishment token the initiator sends
// in RPCSEC_GSS_INIT: the GSS framed AP-REQ. The service key is the
// long term key the ticket is sealed with, as a KDC would have it.
func ClientInit(etype uint32, serviceKey []byte, realm, service, client string) (*Context, []byte, error) {
session := make([]byte, len(serviceKey))
if _, err := rand.Read(session); err != nil {
return nil, nil, err
}
ctx := &Context{Key: session, Etype: etype}
ticket, err := buildTicket(realm, service, session, serviceKey, etype, time.Now().Add(time.Hour))
if err != nil {
return nil, nil, err
}
auth, err := buildAuthenticator(realm, client, session, etype, 0, time.Now())
if err != nil {
return nil, nil, err
}
apreq := appTag(14, derTLV(tagSequence, append(append(
derInt(5), derInt(14)...),
append(bitFlags(0), append(ticket, auth...)...)...)))
inner := append(append([]byte{}, derOID...), apreq...)
return ctx, appTag(0, inner), nil
}
// checkGSSChecksum validates the 0x8003 checksum body of RFC 4121
// section 4.1.1: the little endian length of Bnd, the sixteen Bnd octets
// and the little endian Flags.
func checkGSSChecksum(payload []byte) error {
r := newDERReader(payload)
typeRaw, err := r.expect(tagInteger)
if err != nil {
return err
}
ctype, err := derUint(typeRaw)
if err != nil || ctype != 0x8003 {
return ErrToken
}
body, err := r.expect(tagOctet)
if err != nil {
return err
}
if len(body) != 24 || binary.LittleEndian.Uint32(body[0:4]) != 16 {
return ErrToken
}
return nil
}
// parsePrincipal reads a PrincipalName and answers its components joined
// with a slash.
func parsePrincipal(payload []byte) (string, error) {
r := newDERReader(payload)
if _, err := r.expect(tagInteger); err != nil { // name type
return "", err
}
strs, err := r.expect(tagSequence)
if err != nil {
return "", err
}
sr := newDERReader(strs)
var parts []string
for {
tag, raw, err := sr.next()
if err != nil {
break
}
if tag != 0x1b {
return "", ErrDER
}
parts = append(parts, string(raw))
}
return strings.Join(parts, "/"), nil
}
// parseAuthenticator reads the fields of a decrypted authenticator the
// acceptor checks: the client principal, the timestamp, the microseconds
// and the sequence number when one is present.
func parseAuthenticator(plain []byte) (client string, ctime time.Time, cusec uint64, seq int64, err error) {
r := newDERReader(plain)
vnoRaw, err := r.expect(tagInteger)
if err != nil {
return
}
vno, err := derUint(vnoRaw)
if err != nil || vno != 5 {
err = ErrToken
return
}
if _, err = r.expect(0x1b); err != nil { // crealm
return
}
cnameRaw, err := r.expect(tagSequence)
if err != nil {
return
}
if client, err = parsePrincipal(cnameRaw); err != nil {
return
}
cksumRaw, err := r.expect(tagSequence) // the GSS checksum, required
if err != nil {
return
}
if err = checkGSSChecksum(cksumRaw); err != nil {
return
}
cusecRaw, err := r.expect(tagInteger)
if err != nil {
return
}
if cusec, err = derUint(cusecRaw); err != nil {
return
}
ctimeRaw, err := r.expect(0x18)
if err != nil {
return
}
if ctime, err = parseKerberosTime(ctimeRaw); err != nil {
return
}
// Past the timestamp this profile writes at most the sequence number,
// an integer; anything else ends the fields it reads.
if tag, payload, terr := r.next(); terr == nil && tag == tagInteger {
v, verr := derUint(payload)
if verr != nil {
err = ErrToken
return
}
seq = int64(v)
}
return
}
// replayKey identifies one authenticator of one client: the principal
// plus the timestamp, microseconds and sequence number it carried.
type replayKey struct {
client string
ctime int64
cusec int64
seq int64
}
// replays is the acceptor wide replay cache. Entries live for the clock
// skew window, beyond which a token is refused for its age anyway.
var replays = struct {
mu sync.Mutex
seen map[replayKey]time.Time
}{seen: make(map[replayKey]time.Time)}
// seenReplay records the authenticator and reports whether one with the
// same key was already accepted.
func seenReplay(k replayKey) bool {
now := time.Now()
replays.mu.Lock()
defer replays.mu.Unlock()
for key, until := range replays.seen {
if now.After(until) {
delete(replays.seen, key)
}
}
if _, ok := replays.seen[k]; ok {
return true
}
replays.seen[k] = now.Add(clockSkew)
return false
}
// AcceptInit verifies the initiator token and answers the AP-REP and
// the established context of the acceptor side. The ticket must not have
// expired, the authenticator must sit inside the clock skew window and
// must never have been accepted before.
func AcceptInit(token []byte, serviceKey []byte) (*Context, []byte, error) {
r := newDERReader(token)
outer, err := r.expect(0x60 | 0)
if err != nil {
return nil, nil, err
}
ir := newDERReader(outer)
if _, err = ir.expect(tagOID); err != nil {
return nil, nil, err
}
apreq, err := ir.expect(0x60 | 14)
if err != nil {
return nil, nil, err
}
sr := newDERReader(apreq)
seqPayload, err := sr.expect(tagSequence)
if err != nil {
return nil, nil, err
}
br := newDERReader(seqPayload)
if _, err = br.expect(tagInteger); err != nil { // pvno
return nil, nil, err
}
if _, err = br.expect(tagInteger); err != nil { // msg-type
return nil, nil, err
}
if _, err = br.expect(tagBitString); err != nil { // ap-options
return nil, nil, err
}
ticketRaw, err := br.expect(0x60 | 1)
if err != nil {
return nil, nil, err
}
authRaw, err := br.expect(tagSequence)
if err != nil {
return nil, nil, err
}
// The ticket: decrypt the encrypted part with the service key.
tr := newDERReader(ticketRaw)
ticketSeq, terr := tr.expect(tagSequence)
if terr != nil {
return nil, nil, terr
}
t := newDERReader(ticketSeq)
if _, err = t.expect(tagInteger); err != nil {
return nil, nil, err
}
if _, err = t.expect(0x1b); err != nil { // realm
return nil, nil, err
}
if _, err = t.expect(tagSequence); err != nil { // sname
return nil, nil, err
}
encTag, encPayload, err := t.next()
if err != nil || encTag != tagSequence {
return nil, nil, ErrDER
}
er := newDERReader(encPayload)
_, epayload, eerr := er.next()
if eerr != nil {
return nil, nil, eerr
}
etype, eerr2 := derUint(epayload)
if eerr2 != nil {
return nil, nil, eerr2
}
if _, err = er.expect(tagInteger); err != nil { // kvno
return nil, nil, err
}
cipherPayload, err := er.expect(tagOctet)
if err != nil {
return nil, nil, err
}
inner, err := Decrypt(uint32(etype), serviceKey, UsageTicket, cipherPayload)
if err != nil {
return nil, nil, err
}
// The decrypted ticket part: flags, key, crealm, cname, transited,
// authtime, endtime, srealm, sname. The key and the endtime matter
// here; this profile writes no starttime, so the second time is the
// endtime.
ir2 := newDERReader(inner)
if _, err = ir2.expect(tagBitString); err != nil {
return nil, nil, err
}
_, keyPayload, err := ir2.next()
if err != nil {
return nil, nil, err
}
kr := newDERReader(keyPayload)
if _, err = kr.expect(tagInteger); err != nil {
return nil, nil, err
}
keyValue, err := kr.expect(tagOctet)
if err != nil {
return nil, nil, err
}
if _, err = ir2.expect(0x1b); err != nil { // crealm
return nil, nil, err
}
if _, err = ir2.expect(tagSequence); err != nil { // cname
return nil, nil, err
}
if _, err = ir2.expect(tagSequence); err != nil { // transited
return nil, nil, err
}
if _, err = ir2.expect(0x18); err != nil { // authtime
return nil, nil, err
}
endRaw, err := ir2.expect(0x18) // endtime
if err != nil {
return nil, nil, err
}
endtime, err := parseKerberosTime(endRaw)
if err != nil {
return nil, nil, err
}
if time.Now().After(endtime) {
return nil, nil, ErrExpired
}
// The authenticator: decrypt with the session key.
ar := newDERReader(authRaw)
atag, apayload, aerr := ar.next()
if aerr != nil {
return nil, nil, aerr
}
if atag != tagInteger {
return nil, nil, ErrDER
}
authEtype, aerr2 := derUint(apayload)
if aerr2 != nil {
return nil, nil, aerr2
}
if _, err = ar.expect(tagInteger); err != nil { // kvno
return nil, nil, err
}
authCipher, err := ar.expect(tagOctet)
if err != nil {
return nil, nil, err
}
plain, err := Decrypt(uint32(authEtype), keyValue, UsageAPReqAuth, authCipher)
if err != nil {
return nil, nil, err
}
client, ctime, cusec, seq, perr := parseAuthenticator(plain)
if perr != nil {
return nil, nil, perr
}
authTime := ctime.Add(time.Duration(cusec) * time.Microsecond)
if d := time.Since(authTime); d > clockSkew || d < -clockSkew {
return nil, nil, ErrSkew
}
if seenReplay(replayKey{client: client, ctime: ctime.Unix(), cusec: int64(cusec), seq: seq}) {
return nil, nil, ErrReplay
}
ctx := &Context{Key: keyValue, Etype: uint32(authEtype), Accepting: true, RecvSeq: 0}
// The AP-REP: pvno, msg-type, the encrypted part under usage twelve
// with the echo of the timestamp and the sequence number zero.
enc, err := Encrypt(uint32(authEtype), keyValue, UsageAPRepAuth,
append(append(derInt(0), derInt(0)...), derInt(0)...))
if err != nil {
return nil, nil, err
}
aprep := appTag(15, derTLV(tagSequence, append(append(
derInt(5), derInt(15)...), encryptedData(uint32(authEtype), enc, 0)...)))
return ctx, aprep, nil
}
// ClientAcceptRep verifies the AP-REP the acceptor answers the
// establishment with and completes the client half of the context: the
// encrypted part must decrypt under the session key with the AP-REP
// usage and carry the message type and version of RFC 4120.
func (c *Context) ClientAcceptRep(token []byte) error {
if c.Key == nil {
return ErrContext
}
r := newDERReader(token)
aprep, err := r.expect(0x60 | 15)
if err != nil {
return err
}
pr := newDERReader(aprep)
seqPayload, err := pr.expect(tagSequence)
if err != nil {
return err
}
sr := newDERReader(seqPayload)
pvnoRaw, err := sr.expect(tagInteger)
if err != nil {
return err
}
pvno, err := derUint(pvnoRaw)
if err != nil || pvno != 5 {
return ErrToken
}
msgTypeRaw, err := sr.expect(tagInteger)
if err != nil {
return err
}
msgType, err := derUint(msgTypeRaw)
if err != nil || msgType != 15 {
return ErrToken
}
encTag, encPayload, err := sr.next()
if err != nil || encTag != tagSequence {
return ErrDER
}
er := newDERReader(encPayload)
_, epayload, eerr := er.next()
if eerr != nil {
return eerr
}
etype, eerr2 := derUint(epayload)
if eerr2 != nil {
return eerr2
}
if _, err = er.expect(tagInteger); err != nil { // kvno
return err
}
cipher, err := er.expect(tagOctet)
if err != nil {
return err
}
if uint32(etype) != c.Etype {
return ErrContext
}
plain, err := Decrypt(uint32(etype), c.Key, UsageAPRepAuth, cipher)
if err != nil {
return err
}
// The decrypted part answers with the client time and usec echo;
// three integers in this profile.
tr := newDERReader(plain)
for range 3 {
if _, err = tr.expect(tagInteger); err != nil {
return ErrToken
}
}
return nil
}
// acceptMIC computes the MIC checksum body of RFC 4121 section 4.2.6.1:
// the checksum operation over the signed data and the token header.
func (c *Context) micSum(header, data []byte) ([]byte, error) {
sum, err := Checksum(c.Etype, c.Key, micUsage(c.Accepting), append(append([]byte{}, data...), header...))
if err != nil {
return nil, err
}
return sum, nil
}
// micUsage answers the key usage of the given side: the initiator
// signs with seven, the acceptor with eight; a verifier always uses the
// usage of the peer who produced the token.
func micUsage(accepting bool) uint32 {
if accepting {
return UsageAcceptorSign
}
return UsageInitiatorSign
}
// sealUsage answers the seal key usage of a side.
func sealUsage(accepting bool) uint32 {
if accepting {
return UsageAcceptorSeal
}
return UsageInitiatorSeal
}
// GetMIC produces the per-message MIC token: the fixed header with the
// sequence number and the checksum over data and header.
func (c *Context) GetMIC(data []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
header := make([]byte, 16)
binary.BigEndian.PutUint16(header[0:], TokenMIC)
flags := byte(0)
if c.Accepting {
flags = FlagSentByAcceptor
}
header[2] = flags
for i := 3; i < 8; i++ {
header[i] = 0xff
}
binary.BigEndian.PutUint64(header[8:], c.SendSeq)
c.SendSeq++
sum, err := c.micSum(header, data)
if err != nil {
return nil, err
}
return append(header, sum...), nil
}
// VerifyMIC checks a peer MIC token against the data.
func (c *Context) VerifyMIC(data, token []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if len(token) < 28 || binary.BigEndian.Uint16(token[0:]) != TokenMIC {
return ErrToken
}
peerFlags := byte(0)
if !c.Accepting {
peerFlags = FlagSentByAcceptor
}
if token[2] != peerFlags {
return ErrContext
}
seq := binary.BigEndian.Uint64(token[8:])
if seq != c.RecvSeq {
return ErrContext
}
c.RecvSeq++
// The token was signed by the peer, whose signing usage differs.
sum, err := Checksum(c.Etype, c.Key, micUsage(!c.Accepting), append(append([]byte{}, data...), token[:16]...))
if err != nil {
return err
}
if !hmac.Equal(sum, token[16:28]) {
return ErrIntegrity
}
return nil
}
// Wrap produces the confidential Wrap token: the header, then the
// plaintext with the header appended encrypted by the profile. No
// filler octets are needed with AES, whose cipher keeps the length.
func (c *Context) Wrap(data []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
header := make([]byte, 16)
binary.BigEndian.PutUint16(header[0:], TokenWrap)
flags := byte(FlagSealed)
if c.Accepting {
flags |= FlagSentByAcceptor
}
header[2] = flags
header[3] = 0xff
binary.BigEndian.PutUint64(header[8:], c.SendSeq)
c.SendSeq++
sealed, err := Encrypt(c.Etype, c.Key, sealUsage(c.Accepting), append(append([]byte{}, data...), header...))
if err != nil {
return nil, err
}
return append(header, sealed...), nil
}
// Unwrap reverses Wrap and returns the plaintext.
func (c *Context) Unwrap(token []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if len(token) < 16+16+12 || binary.BigEndian.Uint16(token[0:]) != TokenWrap {
return nil, ErrToken
}
peerFlags := byte(FlagSealed)
if !c.Accepting {
peerFlags |= FlagSentByAcceptor
}
if token[2]&0x03 != peerFlags&0x03 {
return nil, ErrContext
}
// The Wrap token carries its own sequence number, which advances the
// receive counter like a MIC does.
if binary.BigEndian.Uint64(token[8:]) != c.RecvSeq {
return nil, ErrContext
}
c.RecvSeq++
// The token was sealed by the peer, whose seal usage differs.
data, err := Decrypt(c.Etype, c.Key, sealUsage(!c.Accepting), token[16:])
if err != nil {
return nil, err
}
if string(data[len(data)-16:]) != string(token[:16]) {
return nil, ErrContext
}
return data[:len(data)-16], nil
}
+239
View File
@@ -0,0 +1,239 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package krb5
import (
"bytes"
"cmp"
"encoding/binary"
"slices"
"sync"
"testing"
"time"
)
func testKeys() []byte {
serviceKey := make([]byte, 32)
for i := range serviceKey {
serviceKey[i] = byte(i)
}
return serviceKey
}
func TestGSSExchange(t *testing.T) {
serviceKey := testKeys()
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
if err != nil {
t.Fatalf("client init: %v", err)
}
if len(token) == 0 || token[0] != 0x60 {
t.Fatalf("context token % x", token[:8])
}
acceptor, reply, err := AcceptInit(token, serviceKey)
if err != nil {
t.Fatalf("accept init: %v", err)
}
if !bytes.Equal(acceptor.Key, initiator.Key) {
t.Fatal("the session keys differ")
}
if len(reply) == 0 {
t.Fatal("no AP-REP")
}
// krb5i: a MIC over the message binds it to the header and the
// sequence counters move in lock step.
message := []byte("the rpc call header bytes")
mic, err := initiator.GetMIC(message)
if err != nil {
t.Fatal(err)
}
if mic[0] != 0x04 || mic[1] != 0x04 {
t.Fatalf("mic token id % x", mic[:2])
}
if err := acceptor.VerifyMIC(message, mic); err != nil {
t.Fatalf("verify: %v", err)
}
// The mirrored direction: the acceptor's own MIC verifies on the
// initiator, and the sequence counters advanced.
mic2, err := acceptor.GetMIC(message)
if err != nil {
t.Fatal(err)
}
if err := initiator.VerifyMIC(message, mic2); err != nil {
t.Fatalf("verify reverse: %v", err)
}
// A replay of the first token is refused.
if err := acceptor.VerifyMIC(message, mic); err != ErrContext {
t.Fatalf("replay accepted: %v", err)
}
// Tampering with a fresh token is refused on the checksum.
tampered := append([]byte{}, message...)
tampered[0] ^= 0x80
tamperedMIC, err := initiator.GetMIC(tampered)
if err != nil {
t.Fatal(err)
}
tamperedMIC[20] ^= 0xff
if err := acceptor.VerifyMIC(tampered, tamperedMIC); err != ErrIntegrity {
t.Fatalf("tampering accepted: %v", err)
}
// krb5p: the Wrap token hides and binds the payload.
secret := []byte("the compound arguments")
wrapped, err := initiator.Wrap(secret)
if err != nil {
t.Fatal(err)
}
if wrapped[0] != 0x05 || wrapped[1] != 0x04 {
t.Fatalf("wrap token id % x", wrapped[:2])
}
if bytes.Contains(wrapped, secret) {
t.Fatal("the plaintext leaked into the wrap token")
}
opened, err := acceptor.Unwrap(wrapped)
if err != nil {
t.Fatalf("unwrap: %v", err)
}
if !bytes.Equal(opened, secret) {
t.Fatalf("unwrapped %q", opened)
}
wrapped[20] ^= 0xff
if _, err := acceptor.Unwrap(wrapped); err == nil {
t.Fatal("tampering accepted")
}
}
// craftAPREQ builds an establishment token the way ClientInit does, with
// the ticket endtime and the authenticator time under the caller's
// control, so the acceptor's checks can be driven from the tests.
func craftAPREQ(t *testing.T, endtime, ctime time.Time) []byte {
t.Helper()
serviceKey := testKeys()
session := make([]byte, 32)
for i := range session {
session[i] = byte(255 - i)
}
ticket, err := buildTicket("EXAMPLE.ORG", "nfs", session, serviceKey, EtypeAES256, endtime)
if err != nil {
t.Fatal(err)
}
auth, err := buildAuthenticator("EXAMPLE.ORG", "petr@EXAMPLE.ORG", session, EtypeAES256, 0, ctime)
if err != nil {
t.Fatal(err)
}
apreq := appTag(14, derTLV(tagSequence, append(append(
derInt(5), derInt(14)...),
append(bitFlags(0), append(ticket, auth...)...)...)))
return appTag(0, append(append([]byte{}, derOID...), apreq...))
}
func TestAcceptInitReplay(t *testing.T) {
serviceKey := testKeys()
token := craftAPREQ(t, time.Now().Add(time.Hour), time.Now())
if _, _, err := AcceptInit(token, serviceKey); err != nil {
t.Fatalf("first accept: %v", err)
}
if _, _, err := AcceptInit(token, serviceKey); err != ErrReplay {
t.Fatalf("replayed accept: %v", err)
}
}
func TestAcceptInitSkew(t *testing.T) {
serviceKey := testKeys()
old := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(-2*clockSkew))
if _, _, err := AcceptInit(old, serviceKey); err != ErrSkew {
t.Fatalf("an authenticator outside the window: %v", err)
}
future := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(2*clockSkew))
if _, _, err := AcceptInit(future, serviceKey); err != ErrSkew {
t.Fatalf("an authenticator from the future: %v", err)
}
}
func TestAcceptInitExpiredTicket(t *testing.T) {
serviceKey := testKeys()
token := craftAPREQ(t, time.Now().Add(-time.Hour), time.Now())
if _, _, err := AcceptInit(token, serviceKey); err != ErrExpired {
t.Fatalf("an expired ticket: %v", err)
}
}
// The per-message token operations of one context serve concurrent
// callers without losing sequence numbers, which the race detector
// watches over.
func TestContextConcurrentTokens(t *testing.T) {
serviceKey := testKeys()
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
if err != nil {
t.Fatal(err)
}
acceptor, _, err := AcceptInit(token, serviceKey)
if err != nil {
t.Fatal(err)
}
const workers, rounds = 4, 25
mics := make([][]byte, workers*rounds)
wrapped := make([][]byte, workers*rounds)
var wg sync.WaitGroup
for w := range workers {
wg.Go(func() {
for i := range rounds {
mic, err := initiator.GetMIC([]byte("concurrent"))
if err != nil {
t.Error(err)
return
}
mics[w*rounds+i] = mic
box, err := initiator.Wrap([]byte("secret"))
if err != nil {
t.Error(err)
return
}
wrapped[w*rounds+i] = box
}
})
}
wg.Wait()
// Every sequence number was handed out exactly once, and the tokens
// verify in order on the other side. The MIC and Wrap tokens share the
// send counter, so the two families interleave in one space.
type sent struct {
seq uint64
mic []byte
wrap []byte
}
total := make([]sent, 0, 2*workers*rounds)
for _, mic := range mics {
total = append(total, sent{seq: binary.BigEndian.Uint64(mic[8:]), mic: mic})
}
for _, box := range wrapped {
total = append(total, sent{seq: binary.BigEndian.Uint64(box[8:]), wrap: box})
}
slices.SortFunc(total, func(a, b sent) int { return cmp.Compare(a.seq, b.seq) })
if len(total) != 2*workers*rounds {
t.Fatalf("%d tokens, want %d", len(total), 2*workers*rounds)
}
for i, tk := range total {
if tk.seq != uint64(i) {
t.Fatalf("token %d carries sequence %d", i, tk.seq)
}
if tk.mic != nil {
if err := acceptor.VerifyMIC([]byte("concurrent"), tk.mic); err != nil {
t.Fatalf("verify: %v", err)
}
continue
}
opened, err := acceptor.Unwrap(tk.wrap)
if err != nil {
t.Fatalf("unwrap: %v", err)
}
if string(opened) != "secret" {
t.Fatalf("unwrapped %q", opened)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The n-fold of RFC 3961 appendix A, ported from the reference
// implementation of MIT krb5: the input is cycled through the least
// common multiple of the input and output byte lengths, and the
// repetitions summed with end-around carry.
package krb5
// NFold stretches in to outBytes octets with every input bit weighted
// equally into every output bit.
func NFold(in []byte, outBytes int) []byte {
inBytes := len(in)
a, b := outBytes, inBytes
for b != 0 {
a, b = b, a%b
}
lcm := outBytes * inBytes / a
out := make([]byte, outBytes)
carry := 0
for i := lcm - 1; i >= 0; i-- {
msbit := ((inBytes << 3) - 1 + ((inBytes<<3)+13)*(i/inBytes) +
((inBytes - i%inBytes) << 3)) % (inBytes << 3)
hi := (inBytes - 1 - (msbit >> 3)) % inBytes
lo := (inBytes - (msbit >> 3)) % inBytes
carry += int(((uint16(in[hi])<<8 | uint16(in[lo])) >> uint((msbit&7)+1)) & 0xff)
carry += int(out[i%outBytes])
out[i%outBytes] = byte(carry)
carry >>= 8
}
if carry != 0 {
for i := outBytes - 1; i >= 0; i-- {
carry += int(out[i])
out[i] = byte(carry)
carry >>= 8
}
}
return out
}
+243
View File
@@ -0,0 +1,243 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"slices"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Attribute numbers of the fattr4 table, RFC 8881 section 5. The numbers
// were verified against the XDR description of the standard rather than
// recalled: an off by one here shows up as garbage on the client.
const (
AttrSupportedAttrs = 0 // bitmap4
AttrType = 1 // enum
AttrFHExpireType = 2 // uint32
AttrChange = 3 // uint64
AttrSize = 4 // uint64
AttrLinkSupport = 5 // bool
AttrSymlinkSupport = 6 // bool
AttrNamedAttr = 7 // bool
AttrFSID = 8 // uint64, uint64
AttrUniqueHandles = 9 // bool
AttrLeaseTime = 10 // uint32
AttrRdattrError = 11 // enum
AttrFileHandle = 19 // opaque
AttrFileID = 20 // uint64
AttrMaxName = 29 // uint32
AttrMaxRead = 30 // uint64
AttrMaxWrite = 31 // uint64
AttrMode = 33 // uint32
AttrNumlinks = 35 // uint32
AttrOwner = 36 // string
AttrOwnerGroup = 37 // string
AttrRawDev = 41 // specdata
AttrSpaceUsed = 45 // uint64
AttrTimeAccessSet = 48 // settime4
AttrTimeModifySet = 54 // settime4
AttrTimeAccess = 47 // nfstime4
AttrTimeMetadata = 52 // nfstime4
AttrTimeModify = 53 // nfstime4
AttrMountedOnFileID = 55 // uint64
AttrSuppattrExclCreat = 75 // bitmap4
)
// SupportedAttrs is the attribute set this server advertises. A client asks
// only for attributes inside the set it read from here.
var SupportedAttrs = OfBits(
AttrSupportedAttrs,
AttrType,
AttrFHExpireType,
AttrChange,
AttrSize,
AttrLinkSupport,
AttrSymlinkSupport,
AttrNamedAttr,
AttrFSID,
AttrUniqueHandles,
AttrFileHandle,
AttrFileID,
AttrMaxName,
AttrMaxRead,
AttrMaxWrite,
AttrMode,
AttrNumlinks,
AttrOwner,
AttrOwnerGroup,
AttrSpaceUsed,
AttrTimeAccess,
AttrTimeMetadata,
AttrTimeModify,
AttrMountedOnFileID,
)
// ServerLimits carries the per export constants the advertised attributes
// report back.
type ServerLimits struct {
MaxName uint32
MaxRead uint64
MaxWrite uint64
}
// DefaultLimits are the limits the server advertises.
var DefaultLimits = ServerLimits{MaxName: 255, MaxRead: 1 << 22, MaxWrite: 1 << 22}
// Attrs holds the values the fattr4 builder emits. A zero field for an
// attribute the server does not have is simply not requested by a client
// that honoured the advertised set.
type Attrs struct {
Type uint32
FHExpireType uint32
Change uint64
Size uint64
LinkSupport bool
SymlinkSupport bool
NamedAttr bool
FSID [2]uint64
UniqueHandles bool
FileHandle []byte
FileID uint64
Mode uint32
Numlinks uint32
Owner string // decimal uid, RFC 8881 section 5.8
OwnerGroup string // decimal gid
SpaceUsed uint64
TimeAccess NfsTime
TimeMetadata NfsTime
TimeModify NfsTime
MountedOnFileID uint64
Limits ServerLimits
// FsLocations and FsLocationsInfo carry the pre encoded attributes
// for referral stubs; nil omits them.
FsLocations []byte
FsLocationsInfo []byte
}
// A NfsTime is an nfstime4: seconds and nanoseconds since the epoch.
type NfsTime struct {
Seconds int64
Nseconds uint32
}
// NfsTimeOf converts a Go time.
func NfsTimeOf(sec int64, nsec uint32) NfsTime { return NfsTime{sec, nsec} }
func appendNfsTime(b []byte, t NfsTime) []byte {
b = xdr.AppendInt64(b, t.Seconds)
return xdr.AppendUint32(b, t.Nseconds)
}
// AppendFattr emits an fattr4 for the requested attributes: the request
// bitmap filtered to what this server has, then the length prefixed list of
// values in numeric order, as the encoding requires.
func AppendFattr(b []byte, request Bitmap, a Attrs) []byte {
var body []byte
response := Bitmap{}
for _, n := range sortedBits(request) {
switch n {
case AttrSupportedAttrs:
body = SupportedAttrs.AppendTo(body)
response = response.With(n)
case AttrType:
body = xdr.AppendUint32(body, a.Type)
response = response.With(n)
case AttrFHExpireType:
body = xdr.AppendUint32(body, a.FHExpireType)
response = response.With(n)
case AttrChange:
body = xdr.AppendUint64(body, a.Change)
response = response.With(n)
case AttrSize:
body = xdr.AppendUint64(body, a.Size)
response = response.With(n)
case AttrLinkSupport:
body = xdr.AppendBool(body, a.LinkSupport)
response = response.With(n)
case AttrSymlinkSupport:
body = xdr.AppendBool(body, a.SymlinkSupport)
response = response.With(n)
case AttrNamedAttr:
body = xdr.AppendBool(body, a.NamedAttr)
response = response.With(n)
case AttrFSID:
body = xdr.AppendUint64(body, a.FSID[0])
body = xdr.AppendUint64(body, a.FSID[1])
response = response.With(n)
case AttrUniqueHandles:
body = xdr.AppendBool(body, a.UniqueHandles)
response = response.With(n)
case AttrFileHandle:
body = xdr.AppendVarOpaque(body, a.FileHandle)
response = response.With(n)
case AttrFileID:
body = xdr.AppendUint64(body, a.FileID)
response = response.With(n)
case AttrMaxName:
body = xdr.AppendUint32(body, a.Limits.MaxName)
response = response.With(n)
case AttrMaxRead:
body = xdr.AppendUint64(body, a.Limits.MaxRead)
response = response.With(n)
case AttrMaxWrite:
body = xdr.AppendUint64(body, a.Limits.MaxWrite)
response = response.With(n)
case AttrMode:
body = xdr.AppendUint32(body, a.Mode)
response = response.With(n)
case AttrNumlinks:
body = xdr.AppendUint32(body, a.Numlinks)
response = response.With(n)
case AttrOwner:
body = xdr.AppendString(body, a.Owner)
response = response.With(n)
case AttrOwnerGroup:
body = xdr.AppendString(body, a.OwnerGroup)
response = response.With(n)
case AttrSpaceUsed:
body = xdr.AppendUint64(body, a.SpaceUsed)
response = response.With(n)
case AttrTimeAccess:
body = appendNfsTime(body, a.TimeAccess)
response = response.With(n)
case AttrTimeMetadata:
body = appendNfsTime(body, a.TimeMetadata)
response = response.With(n)
case AttrTimeModify:
body = appendNfsTime(body, a.TimeModify)
response = response.With(n)
case AttrMountedOnFileID:
body = xdr.AppendUint64(body, a.MountedOnFileID)
response = response.With(n)
case AttrFsLocations:
if a.FsLocations != nil {
body = append(body, a.FsLocations...)
response = response.With(n)
}
case AttrFsLocationsInfo:
if a.FsLocationsInfo != nil {
body = append(body, a.FsLocationsInfo...)
response = response.With(n)
}
}
}
b = response.AppendTo(b)
return xdr.AppendVarOpaque(b, body)
}
// sortedBits lists the attribute numbers set in the bitmap, ascending.
func sortedBits(m Bitmap) []uint32 {
var out []uint32
for w, word := range m.Words() {
for bit := range 32 {
if word&(1<<bit) != 0 {
out = append(out, uint32(w*32+bit))
}
}
}
slices.Sort(out)
return out
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"slices"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// maxBitmapWords is the word count limit of a bitmap4. The protocol caps
// the array at 100 words; the attribute table today ends far below that.
const maxBitmapWords = 100
// A Bitmap is a bitmap4: a compact set of attribute numbers.
type Bitmap []uint32
// OfBits returns a Bitmap carrying exactly the given attribute numbers.
func OfBits(bits ...uint32) Bitmap {
if len(bits) == 0 {
return Bitmap{}
}
var words Bitmap
for _, n := range bits {
w := int(n / 32)
for len(words) <= w {
words = append(words, 0)
}
words[w] |= 1 << (n % 32)
}
return words
}
// Has reports whether the attribute number n is set.
func (m Bitmap) Has(n uint32) bool {
w := int(n / 32)
return w < len(m) && m[w]&(1<<(n%32)) != 0
}
// With returns a bitmap carrying n in addition to what was already set.
// Words the original bitmap already held are never dropped, even when the
// new bit lands in a lower word.
func (m Bitmap) With(n uint32) Bitmap {
w := int(n / 32)
out := make(Bitmap, max(w+1, len(m)))
copy(out, m)
out[w] |= 1 << (n % 32)
return out
}
// AppendTo appends the bitmap in wire form: a count of words followed by
// the words, with trailing zero words trimmed.
func (m Bitmap) AppendTo(b []byte) []byte {
trimmed := m
for len(trimmed) > 0 && trimmed[len(trimmed)-1] == 0 {
trimmed = trimmed[:len(trimmed)-1]
}
b = xdr.AppendUint32(b, uint32(len(trimmed)))
for _, w := range trimmed {
b = xdr.AppendUint32(b, w)
}
return b
}
// ReadBitmap reads a bitmap4. A bitmap beyond the word limit is refused
// before any of it is buffered.
func ReadBitmap(d *xdr.Decoder) (Bitmap, error) {
n, err := d.Uint32()
if err != nil {
return nil, err
}
if n > maxBitmapWords {
return nil, xdr.ErrBadLength
}
m := make(Bitmap, 0, n)
for range n {
w, err := d.Uint32()
if err != nil {
return nil, err
}
m = append(m, w)
}
return m, nil
}
// Words reports the words of the bitmap, without trailing zero words.
func (m Bitmap) Words() []uint32 {
trimmed := slices.Clone(m)
for len(trimmed) > 0 && trimmed[len(trimmed)-1] == 0 {
trimmed = trimmed[:len(trimmed)-1]
}
return trimmed
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The callback channel of RFC 8881: the CB_COMPOUND procedure the server
// calls on the connection the client opened, CB_SEQUENCE driving the back
// channel slot table, and CB_RECALL recalling a delegation.
package nfs4
import (
"fmt"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Callback operation numbers, RFC 8881 section 15.5.
const (
OpCBGetattr = 3
OpCBRecall = 4
OpCBSequence = 11
OpCBPushDeleg = 7
OpCBNotify = 6
OpCBNotifyLock = 13
)
// The callback program rides on the same minor version and the COMPOUND
// shape as the fore channel.
const (
CBDefaultProgram = 0x40000000
)
// AppendCBCompoundArgs encodes the CB_COMPOUND4args: the tag, the minor
// version, the callback identifier and the operations. The callback
// identifier is the slot the server reserved for this client when the
// back channel was negotiated; with sessions it stays zero.
func AppendCBCompoundArgs(b []byte, tag string, minorversion, callbackIdent uint32, ops [][]byte) []byte {
b = xdr.AppendString(b, tag)
b = xdr.AppendUint32(b, minorversion)
b = xdr.AppendUint32(b, callbackIdent)
b = xdr.AppendUint32(b, uint32(len(ops)))
for _, op := range ops {
b = append(b, op...)
}
return b
}
// CBCompoundHeader is the head of a decoded CB_COMPOUND4args.
type CBCompoundHeader struct {
Tag string
Minor uint32
CallbackIdent uint32
OpCount uint32
}
// DecodeCBCompoundArgs splits CB_COMPOUND4args into its header and a
// decoder positioned at the first argop.
func DecodeCBCompoundArgs(payload []byte) (CBCompoundHeader, *xdr.Decoder, error) {
d := xdr.NewDecoder(payload)
var h CBCompoundHeader
var err error
if h.Tag, err = d.String(); err != nil {
return h, nil, err
}
if h.Minor, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.CallbackIdent, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount > maxOps {
return h, nil, fmt.Errorf("nfs4: %d operations exceeds %d", h.OpCount, maxOps)
}
return h, d, nil
}
// AppendCBCompoundRes encodes a CB_COMPOUND4res: the top level status, the
// echoed tag and the result array.
func AppendCBCompoundRes(b []byte, status uint32, tag string, results [][]byte) []byte {
return AppendCompoundRes(b, status, tag, results)
}
// AppendCBSequenceArgs encodes the CB_SEQUENCE argop.
func AppendCBSequenceArgs(b []byte, id SessionID, sequence, slot, highestSlot uint32, cacheThis bool) []byte {
b = xdr.AppendUint32(b, OpCBSequence)
return AppendSequenceArgsTail(b, id, sequence, slot, highestSlot, cacheThis)
}
// AppendSequenceArgsTail appends the shared SEQUENCE argument fields: the
// session id, the sequence, the slot ids and the caching flag.
func AppendSequenceArgsTail(b []byte, id SessionID, sequence, slot, highestSlot uint32, cacheThis bool) []byte {
b = append(b, id[:]...)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, slot)
b = xdr.AppendUint32(b, highestSlot)
return xdr.AppendBool(b, cacheThis)
}
// A CBSequenceArgs is the decoded CB_SEQUENCE4args.
type CBSequenceArgs struct {
SessionID SessionID
Sequence uint32
Slot uint32
HighestSlot uint32
CacheThis bool
}
// DecodeCBSequenceArgs decodes the CB_SEQUENCE4args from the decoder,
// which is positioned after the operation number.
func DecodeCBSequenceArgs(d *xdr.Decoder) (CBSequenceArgs, error) {
var a CBSequenceArgs
raw, err := d.Raw(16)
if err != nil {
return a, err
}
copy(a.SessionID[:], raw)
var err2 error
if a.Sequence, err2 = d.Uint32(); err2 != nil {
return a, err2
}
if a.Slot, err2 = d.Uint32(); err2 != nil {
return a, err2
}
if a.HighestSlot, err2 = d.Uint32(); err2 != nil {
return a, err2
}
if a.CacheThis, err2 = d.Bool(); err2 != nil {
return a, err2
}
return a, nil
}
// AppendCBSequenceRes encodes the successful CB_SEQUENCE4resok of
// RFC 7863: the session id, the echoed identifiers and the target
// highest slot. No further word follows.
func AppendCBSequenceRes(b []byte, id SessionID, sequence, slot, highestSlot uint32) []byte {
b = append(b, id[:]...)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, slot)
b = xdr.AppendUint32(b, highestSlot)
return xdr.AppendUint32(b, highestSlot) // cbsr_target_highest_slotid
}
// DecodeCBSequenceRes decodes the successful CB_SEQUENCE4resok.
func DecodeCBSequenceRes(d *xdr.Decoder) (id SessionID, sequence, slot, highestSlot, targetHighestSlot uint32, err error) {
raw, rerr := d.Raw(16)
if rerr != nil {
return id, 0, 0, 0, 0, rerr
}
copy(id[:], raw)
if sequence, err = d.Uint32(); err != nil {
return
}
if slot, err = d.Uint32(); err != nil {
return
}
if highestSlot, err = d.Uint32(); err != nil {
return
}
targetHighestSlot, err = d.Uint32()
return
}
// AppendCBRecallArgs encodes the CB_RECALL argop: the delegation stateid,
// whether to truncate, and the file handle the delegation names.
func AppendCBRecallArgs(b []byte, stateid Stateid, truncate bool, fh []byte) []byte {
b = xdr.AppendUint32(b, OpCBRecall)
b = stateid.Append(b)
b = xdr.AppendBool(b, truncate)
return xdr.AppendVarOpaque(b, fh)
}
// A CBRecallArgs is the decoded CB_RECALL argop.
type CBRecallArgs struct {
Stateid Stateid
Truncate bool
FH []byte
}
// DecodeCBRecallArgs decodes the CB_RECALL args from the decoder, which is
// positioned after the operation number.
func DecodeCBRecallArgs(d *xdr.Decoder) (CBRecallArgs, error) {
var a CBRecallArgs
raw, err := d.Raw(16)
if err != nil {
return a, err
}
copy(a.Stateid[:], raw)
var err2 error
if a.Truncate, err2 = d.Bool(); err2 != nil {
return a, err2
}
a.FH, err2 = d.VarOpaque()
return a, err2
}
// AppendCBGetattrArgs encodes the CB_GETATTR argop: the handle and the
// requested attribute bitmap.
func AppendCBGetattrArgs(b []byte, fh []byte, request Bitmap) []byte {
b = xdr.AppendUint32(b, OpCBGetattr)
b = xdr.AppendVarOpaque(b, fh)
return request.AppendTo(b)
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestCBCompoundRoundTrip(t *testing.T) {
ops := [][]byte{
AppendCBSequenceArgs(nil, MakeSessionID([8]byte{}, 5), 1, 0, 0, true),
AppendCBRecallArgs(nil, AllZero, false, []byte{9, 9}),
}
arg := AppendCBCompoundArgs(nil, "cb", 2, 0, ops)
h, d, err := DecodeCBCompoundArgs(arg)
if err != nil {
t.Fatalf("decode: %v", err)
}
if h.Tag != "cb" || h.Minor != 2 || h.CallbackIdent != 0 || h.OpCount != 2 {
t.Fatalf("header: %+v", h)
}
if op, err := d.Uint32(); err != nil || op != OpCBSequence {
t.Fatalf("op 0: %d, %v", op, err)
}
a, err := DecodeCBSequenceArgs(d)
if err != nil {
t.Fatalf("cb sequence: %v", err)
}
if a.Sequence != 1 || a.Slot != 0 || !a.CacheThis {
t.Fatalf("cb sequence args: %+v", a)
}
if op, err := d.Uint32(); err != nil || op != OpCBRecall {
t.Fatalf("op 1: %d, %v", op, err)
}
r, err := DecodeCBRecallArgs(d)
if err != nil || r.Truncate || !bytes.Equal(r.FH, []byte{9, 9}) {
t.Fatalf("cb recall args: %+v, %v", r, err)
}
}
func TestCBSequenceResShape(t *testing.T) {
id := MakeSessionID([8]byte{}, 0x55)
res := AppendCBSequenceRes(nil, id, 9, 2, 7)
if len(res) != 32 {
t.Fatalf("res of %d bytes, want 32", len(res))
}
d := xdr.NewDecoder(res)
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, id[:]) {
t.Fatalf("session id: %x, %v", raw, err)
}
for _, want := range []uint32{9, 2, 7, 7} {
if v, err := d.Uint32(); err != nil || v != want {
t.Fatalf("word: %d, %v; want %d", v, err, want)
}
}
sid, seq, slot, high, target, err := DecodeCBSequenceRes(xdr.NewDecoder(res))
if err != nil || sid != id || seq != 9 || slot != 2 || high != 7 || target != 7 {
t.Fatalf("decode: %d %d %d %d %d, %v", seq, slot, high, target, 0, err)
}
}
func TestCBGetattrArgs(t *testing.T) {
arg := AppendCBGetattrArgs(nil, []byte("fh"), OfBits(AttrSize, AttrChange))
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpCBGetattr {
t.Fatalf("op: %d, %v", op, err)
}
if fh, err := d.VarOpaque(); err != nil || string(fh) != "fh" {
t.Fatalf("fh: %q, %v", fh, err)
}
if m, err := ReadBitmap(d); err != nil || !m.Has(AttrSize) || !m.Has(AttrChange) {
t.Fatalf("bitmap: %v", m.Words())
}
}
func TestDecodeCompoundResBodiesCB(t *testing.T) {
var results [][]byte
seqBody := append(AppendOpHeader(nil, OpCBSequence, ErrOK),
AppendCBSequenceRes(nil, MakeSessionID([8]byte{}, 3), 4, 0, 7)...)
results = append(results, seqBody, AppendOpHeader(nil, OpCBRecall, ErrOK))
payload := AppendCBCompoundRes(nil, ErrOK, "cbt", results)
res, bodies, err := DecodeCompoundResBodiesCB(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if res.Status != ErrOK || len(res.Ops) != 2 {
t.Fatalf("res: status %d ops %d", res.Status, len(res.Ops))
}
if len(bodies[0]) != 32 || len(bodies[1]) != 0 {
t.Fatalf("bodies: %d and %d", len(bodies[0]), len(bodies[1]))
}
// A failed op carries no body.
payload = AppendCBCompoundRes(nil, ErrNotSupp, "", [][]byte{
AppendOpHeader(nil, OpCBGetattr, ErrNotSupp),
})
res, bodies, err = DecodeCompoundResBodiesCB(payload)
if err != nil || res.Status != ErrNotSupp || len(bodies[0]) != 0 {
t.Fatalf("failed op: status %d body %d, %v", res.Status, len(bodies[0]), err)
}
}
func TestDelegationCodecRoundTrip(t *testing.T) {
var st Stateid
st[0] = 3
st[4], st[5], st[6], st[7] = 'D', 'E', 'L', 'E'
// READ delegation round trip. The res body ends with the NONE type,
// which the READ delegation replaces.
base := AppendOpenRes(nil, AllZero)
body := AppendDelegation(base[:len(base)-4], OpenDelegRead, st, false, 0)
openSt, delegType, delegSt, err := DecodeOpenResDeleg(body)
if err != nil {
t.Fatalf("read decode: %v", err)
}
if delegType != OpenDelegRead || delegSt != st || openSt != AllZero {
t.Fatalf("read delegation: %+v", delegSt)
}
// WRITE delegation round trip with a space limit.
body = AppendDelegation(base[:len(base)-4], OpenDelegWrite, st, true, 1<<20)
openSt, delegType, delegSt, err = DecodeOpenResDeleg(body)
if err != nil {
t.Fatalf("write decode: %v", err)
}
if delegType != OpenDelegWrite || delegSt != st {
t.Fatalf("write delegation: %d, %v", delegType, err)
}
// NONE carries nothing beyond the type.
body = AppendDelegation(base[:len(base)-4], OpenDelegNone, st, false, 0)
_, delegType, _, derr := DecodeOpenResDeleg(body)
if derr != nil || delegType != OpenDelegNone {
t.Fatalf("none: %d, %v", delegType, derr)
}
if n, err := resBodyLenCB(OpCBSequence, ErrOK, append(AppendOpHeader(nil, OpCBSequence, ErrOK),
AppendCBSequenceRes(nil, MakeSessionID([8]byte{}, 1), 1, 0, 7)...)); err != nil || n != 32 {
t.Fatalf("cb seq measure: %d, %v", n, err)
}
}
func seqBodyHelper() []byte {
return append(AppendOpHeader(nil, OpCBSequence, ErrOK),
AppendCBSequenceRes(nil, MakeSessionID([8]byte{}, 1), 1, 0, 7)...)
}
func TestAppendOpenArgsPrevious(t *testing.T) {
arg := AppendOpenArgsPrevious(nil, 0x7777, []byte("owner"), ShareAccessBoth, 0)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpOpen {
t.Fatalf("op: %d, %v", op, err)
}
if seq, err := d.Uint32(); err != nil || seq != 0 {
t.Fatalf("seqid: %v", err)
}
if access, err := d.Uint32(); err != nil || access != ShareAccessBoth {
t.Fatalf("access: %v", err)
}
if deny, err := d.Uint32(); err != nil || deny != 0 {
t.Fatalf("deny: %v", err)
}
if cid, err := d.Uint64(); err != nil || cid != 0x7777 {
t.Fatalf("clientid: %v", err)
}
owner, err := d.VarOpaque()
if err != nil || string(owner) != "owner" {
t.Fatalf("owner: %q, %v", owner, err)
}
if ot, err := d.Uint32(); err != nil || ot != 0 {
t.Fatalf("opentype: %d, %v", ot, err)
}
if claim, err := d.Uint32(); err != nil || claim != ClaimPrevious {
t.Fatalf("claim: %d, %v", claim, err)
}
}
func TestAppendOpenArgsPreviousRoundTrip(t *testing.T) {
arg := AppendOpenArgsPrevious(nil, 0x7777, []byte("owner"), ShareAccessBoth, 0)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpOpen {
t.Fatalf("op: %d, %v", op, err)
}
if seq, err := d.Uint32(); err != nil || seq != 0 {
t.Fatalf("seqid: %v", err)
}
if access, err := d.Uint32(); err != nil || access != ShareAccessBoth {
t.Fatalf("access: %v", err)
}
if deny, err := d.Uint32(); err != nil || deny != 0 {
t.Fatalf("deny: %v", err)
}
if cid, err := d.Uint64(); err != nil || cid != 0x7777 {
t.Fatalf("clientid: %v", err)
}
owner, err := d.VarOpaque()
if err != nil || string(owner) != "owner" {
t.Fatalf("owner: %q, %v", owner, err)
}
if ot, err := d.Uint32(); err != nil || ot != 0 {
t.Fatalf("opentype: %d, %v", ot, err)
}
if claim, err := d.Uint32(); err != nil || claim != ClaimPrevious {
t.Fatalf("claim: %d, %v", claim, err)
}
}
func TestDecodeOpenResDelegTruncated(t *testing.T) {
// Every proper prefix of a READ delegation answer must fail to decode.
st := AllZero
st[0] = 3
base := AppendOpenRes(nil, AllZero)
base = base[:len(base)-4] // drop the NONE delegation type
full := append(append([]byte{}, base...), AppendDelegation(nil, OpenDelegRead, st, false, 0)...)
for n := 4; n < len(full); n++ {
if _, _, _, err := DecodeOpenResDeleg(full[:n]); err == nil {
t.Fatalf("a %d byte prefix decoded", n)
}
}
}
func TestDecodeSessionChannelRoundTrip(t *testing.T) {
// No RDMA: six words and an empty size array.
var body []byte
body = xdr.AppendUint32(body, 0)
body = xdr.AppendUint32(body, 1<<20)
body = xdr.AppendUint32(body, 1<<20)
body = xdr.AppendUint32(body, 1<<20)
body = xdr.AppendUint32(body, 8)
body = xdr.AppendUint32(body, 8)
body = xdr.AppendUint32(body, 0)
c, err := DecodeSessionChannel(xdr.NewDecoder(body))
if err != nil {
t.Fatalf("decode: %v", err)
}
if c.MaxRequests != 8 || c.RdmaIRDEnabled {
t.Fatalf("channel: %+v", c)
}
}
func TestDecodeExchangeIDArgsTruncated(t *testing.T) {
// AppendLockArgsNew writes the op, the open seqid, the open stateid,
// the lock seqid, the client id and the owner; the truncation test
// walks the payload after the op number.
full := AppendExchangeIDArgs(nil, [8]byte{1}, []byte("own"), 0)
for n := 1; n < len(full)-4; n++ {
if _, err := DecodeExchangeIDArgs(xdr.NewDecoder(full[4 : 4+n])); err == nil {
t.Fatalf("a %d byte prefix decoded", n)
}
}
}
+823
View File
@@ -0,0 +1,823 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"errors"
"fmt"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// maxOps bounds the COMPOUND arrays on both encode and decode. The
// protocol has no fixed limit; a request longer than this is not a client,
// it is an attack.
const maxOps = 1024
// maxTag bounds the COMPOUND tag.
const maxTag = 128
// AppendCompoundArgs encodes the COMPOUND4args: the tag, the minor version
// and the operation array, where each element is an encoded argop: its
// operation number followed by its own arguments.
func AppendCompoundArgs(b []byte, tag string, minor uint32, ops [][]byte) []byte {
b = xdr.AppendString(b, tag)
b = xdr.AppendUint32(b, minor)
b = xdr.AppendUint32(b, uint32(len(ops)))
for _, op := range ops {
b = append(b, op...)
}
return b
}
// CompoundHeader is the head of a decoded COMPOUND4args: the tag, the minor
// version and the operation count. The returned decoder is positioned at
// the first argop, and the dispatcher reads each operation's own arguments
// from it as it dispatches.
type CompoundHeader struct {
Tag string
Minor uint32
OpCount uint32
}
// DecodeCompoundArgs splits COMPOUND4args into its header and a decoder
// over the argops.
func DecodeCompoundArgs(payload []byte) (CompoundHeader, *xdr.Decoder, error) {
d := xdr.NewDecoder(payload)
var h CompoundHeader
var err error
if h.Tag, err = d.String(); err != nil {
return h, nil, err
}
if len(h.Tag) > maxTag {
return h, nil, fmt.Errorf("nfs4: tag of %d bytes exceeds %d", len(h.Tag), maxTag)
}
if h.Minor, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount, err = d.Uint32(); err != nil {
return h, nil, err
}
if h.OpCount > maxOps {
return h, nil, fmt.Errorf("nfs4: %d operations exceeds %d", h.OpCount, maxOps)
}
return h, d, nil
}
// AppendCompoundRes encodes the COMPOUND4res: the top level status, the
// echoed tag and the result array. On success the results hold one entry
// per executed operation; on failure they hold the results up to and
// including the operation that failed, and the top level status carries its
// status.
func AppendCompoundRes(b []byte, status uint32, tag string, results [][]byte) []byte {
b = xdr.AppendUint32(b, status)
b = xdr.AppendString(b, tag)
b = xdr.AppendUint32(b, uint32(len(results)))
for _, op := range results {
b = append(b, op...)
}
return b
}
// AppendOpHeader appends the head of one nfs_resop4: the operation number
// and its status. A successful result appends its payload afterwards; a
// failed one is complete as it stands.
func AppendOpHeader(b []byte, op, status uint32) []byte {
b = xdr.AppendUint32(b, op)
return xdr.AppendUint32(b, status)
}
// A ResOp is one decoded result operation: its number, its status, and the
// bytes that follow the status. Body is meaningful only while the status is
// NFS4ERR_OK, and its shape depends on the operation.
type ResOp struct {
Op uint32
Status uint32
Body []byte
}
// A CompoundRes is the decoded COMPOUND4res.
type CompoundRes struct {
Status uint32
Tag string
Ops []ResOp
}
// DecodeCompoundResBodies decodes a COMPOUND4res and, for every operation,
// the bytes between its status and the next operation number. A body is
// empty when the operation carried no payload or when it failed. The
// bodies are not length prefixed on the wire: the length of each follows
// from its operation number, so the decoder measures each body as it goes.
func DecodeCompoundResBodies(payload []byte) (CompoundRes, [][]byte, error) {
d := xdr.NewDecoder(payload)
var res CompoundRes
var err error
if res.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
if res.Tag, err = d.String(); err != nil {
return res, nil, err
}
count, err := d.Uint32()
if err != nil {
return res, nil, err
}
if count > maxOps {
return res, nil, fmt.Errorf("nfs4: %d results exceeds %d", count, maxOps)
}
var ops []ResOp
var bodies [][]byte
for range count {
var op ResOp
if op.Op, err = d.Uint32(); err != nil {
return res, nil, err
}
if op.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
start := len(payload) - d.Remaining()
n, err := resBodyLen(op.Op, op.Status, payload[start:])
if err != nil {
return res, nil, err
}
if n < 0 || start+n > len(payload) {
return res, nil, fmt.Errorf("nfs4: result body of %d bytes overruns the payload", n)
}
op.Body = payload[start : start+n]
if _, err := d.Raw(n); err != nil {
return res, nil, err
}
ops = append(ops, op)
bodies = append(bodies, op.Body)
}
if d.Remaining() > 0 {
return res, nil, fmt.Errorf("nfs4: %d bytes trail the declared operations", d.Remaining())
}
res.Ops = ops
return res, bodies, nil
}
// resBodyLen measures how many bytes one result body occupies. A failed
// operation carries no body. An operation this build cannot measure, with a
// successful status, is a response the client has no business receiving.
func resBodyLen(op, status uint32, body []byte) (int, error) {
// Every failed operation carries an empty body, except LOCKT: its
// DENIED answer names the conflicting lock.
if status != ErrOK && op != OpLockt {
return 0, nil
}
d := xdr.NewDecoder(body)
switch op {
case OpPutfh, OpPutRootfh, OpLookup, OpLookupp, OpSavefh, OpRestorefh:
return 0, nil
case OpGetfh:
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpGetattr:
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpAccess:
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
case OpCreate:
// changeinfo4 (atomic, before, after) then the bitmap of the
// attributes set, RFC 8881 section 18.4.2.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpWrite:
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
case OpReadlink:
// The link text, length prefixed.
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
return len(body) - d.Remaining(), nil
case OpCommit:
// The write verifier.
return 8, nil
case OpSecinfo, OpSecinfoNoName:
// An array of secinfo4 unions: the flavour, and the
// rpcsec_gss_info body only on the RPCSEC_GSS branch.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n > maxOps {
return 0, ErrUnknownOp
}
for range n {
flavor, ferr := d.Uint32()
if ferr != nil {
return 0, ferr
}
if flavor == SecFlavorGSS {
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
}
return len(body) - d.Remaining(), nil
case OpRemove, OpOpenDowngrade:
// change_info4: an atomic flag and two 64 bit counters.
return 20, nil
case OpDestroyClientID, OpReclaimComplete, OpPutPubfh,
OpReleaseLockOwner, OpDelegReturn, OpFreeStateid, OpBackchannelCtl,
OpVerify, OpNverify:
return 0, nil
case OpBindConnToSession:
// sessionid and the granted direction.
return 20, nil
case OpTestStateid:
// One status per stateid the arguments named.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n > maxOps {
return 0, ErrUnknownOp
}
return 4 + 4*int(n), nil
case OpRename:
// Two change_info4 values, one per directory.
return 40, nil
case OpLink:
// change_info4 of the target directory.
return 20, nil
case OpSequence:
// sessionid and the five words of SEQUENCE4resok, RFC 7863.
if _, err := d.Raw(16); err != nil {
return 0, err
}
for range 5 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
return 36, nil
case OpDestroySession:
return 0, nil
case OpExchangeID:
// clientid, sequence, flags, state protect how, eir_server_owner
// (uint64 minor id, opaque major id), eir_server_scope and the
// impl_id array, RFC 8881 section 18.35.
if _, err := d.Uint64(); err != nil {
return 0, err
}
for range 3 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
for range 2 { // so_major_id then server_scope
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
for range 2 { // domain and name
if _, err := d.String(); err != nil {
return 0, err
}
}
if _, err := d.Raw(12); err != nil { // nfstime4
return 0, err
}
}
return len(body) - d.Remaining(), nil
case OpCreateSession:
// sessionid, sequence, flags and two channel attrs.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
for range 2 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
for range 5 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
count, cerr := d.Uint32()
if cerr != nil {
return 0, cerr
}
if count != 0xffffffff {
for range count {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
}
}
return len(body) - d.Remaining(), nil
case OpOpen:
// stateid, change_info4, rflags, attrset and the delegation union.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
delegType, derr := d.Uint32()
if derr != nil {
return 0, derr
}
switch delegType {
case OpenDelegRead:
// stateid, recall flag and the permissions ace.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if err := walkACE(d); err != nil {
return 0, err
}
case OpenDelegWrite:
// stateid, recall flag, the space limit union and the ace.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if err := walkSpaceLimit(d); err != nil {
return 0, err
}
if err := walkACE(d); err != nil {
return 0, err
}
}
return len(body) - d.Remaining(), nil
case OpClose:
return 16, nil
case OpLock, OpLocku:
// The stateid.
return 16, nil
case OpLockt:
return resBodyLenLockt(status, body)
case OpSetattr:
// attrsset: only a bitmap4.
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpRead:
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpReaddir:
if _, err := d.Raw(8); err != nil {
return 0, err
}
for {
more, err := d.Bool()
if err != nil {
return 0, err
}
if !more {
break
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.String(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpLayoutGet:
// The layout stateid, the return on close flag and the layout
// array: offset, length, IO mode, type and body per segment.
if _, err := d.Raw(16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
}
case OpLayoutCommit:
// The newsize4 union: the changed flag and the size.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
case OpLayoutReturn:
// The layoutreturn_stateid4 union: the returned flag and the
// stateid.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Raw(16); err != nil {
return 0, err
}
case OpSeek:
// The end of file flag and the offset.
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
case OpAllocate, OpDeallocate, OpClone, OpLayoutError, OpLayoutStats, OpOffloadCancel:
return 0, nil
case OpIoAdvise:
// The accepted hints bitmap4.
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
case OpCopy:
// write_response4 then the two requirement flags.
n, err := d.Uint32()
if err != nil {
return 0, err
}
if n == 1 {
if _, err := d.Raw(16); err != nil {
return 0, err
}
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpCopyNotify:
// lease time, empty owner confirm, stateid, source list.
if _, err := d.Int64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(16); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
kind, kerr := d.Uint32()
if kerr != nil {
return 0, kerr
}
switch kind {
case 1, 2:
if _, err := d.String(); err != nil {
return 0, err
}
default:
if _, err := d.String(); err != nil {
return 0, err
}
if _, err := d.String(); err != nil {
return 0, err
}
}
}
case OpOffloadStatus:
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
case OpReadPlus:
// eof flag and the content array.
if _, err := d.Bool(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
kind, kerr := d.Uint32()
if kerr != nil {
return 0, kerr
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if kind == uint32(ContentData) {
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
} else if _, err := d.Uint64(); err != nil {
return 0, err
}
}
case OpWriteSame:
// write_response4 without a callback arm.
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
case OpGetDirDelegation:
// The non fatal union: the status, then the resok body or the
// will signal flag.
gdd, err := d.Uint32()
if err != nil {
return 0, err
}
if gdd == GddOK {
if _, err := d.Raw(24); err != nil {
return 0, err
}
for range 3 {
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
}
} else if _, err := d.Bool(); err != nil {
return 0, err
}
case OpOpenattr:
return 0, nil
case OpGetDeviceList:
// The cookie, its verifier, the device id array and the eof flag.
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Raw(8); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
if _, err := d.Raw(int(n) * 16); err != nil {
return 0, err
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpGetxattr:
// The single opaque value.
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
case OpSetxattr, OpRemovexattr:
return 0, nil
case OpListxattr:
// The cookie of the last name, the names and the end of list
// flag.
if _, err := d.Uint64(); err != nil {
return 0, err
}
n, err := d.Uint32()
if err != nil {
return 0, err
}
for range n {
if _, err := d.String(); err != nil {
return 0, err
}
}
if _, err := d.Bool(); err != nil {
return 0, err
}
case OpGetDeviceInfo:
// The address type, the address body and the notification bitmap.
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
if _, err := ReadBitmap(d); err != nil {
return 0, err
}
default:
return 0, ErrUnknownOp
}
return len(body) - d.Remaining(), nil
}
// walkACE walks one nfsace4 of a result body.
func walkACE(d *xdr.Decoder) error {
for range 3 {
if _, err := d.Uint32(); err != nil {
return err
}
}
_, err := d.VarOpaque()
return err
}
// walkSpaceLimit walks one nfs_space_limit4 union of a result body.
func walkSpaceLimit(d *xdr.Decoder) error {
limitBy, err := d.Uint32()
if err != nil {
return err
}
switch limitBy {
case LimitBySize:
_, err = d.Uint64()
return err
case 2: // NFS_LIMIT_BLOCKS
if _, err = d.Uint32(); err != nil {
return err
}
_, err = d.Uint32()
return err
default:
return ErrBadDeleg
}
}
// ErrUnknownOp marks a successful result whose shape this build cannot
// measure: the client and the server disagree on what was sent.
var ErrUnknownOp = errors.New("nfs4: unknown operation result")
// resBodyLenCB measures one CB result body. CB operation numbers share
// values with the fore channel, so the two families are never mixed in
// one measurement.
func resBodyLenCB(op, status uint32, body []byte) (int, error) {
if status != ErrOK {
return 0, nil
}
d := xdr.NewDecoder(body)
switch op {
case OpCBSequence:
// The session id and the four words of CB_SEQUENCE4resok,
// RFC 7863.
if _, err := d.Raw(16); err != nil {
return 0, err
}
for range 4 {
if _, err := d.Uint32(); err != nil {
return 0, err
}
}
return 32, nil
case OpCBRecall, OpCBNotify, OpCBNotifyLock:
return 0, nil
default:
return 0, nil
}
}
// DecodeCompoundResBodiesCB decodes a CB_COMPOUND reply: the same walk as
// the fore channel variant, with the callback operation shapes.
func DecodeCompoundResBodiesCB(payload []byte) (CompoundRes, [][]byte, error) {
d := xdr.NewDecoder(payload)
var res CompoundRes
var err error
if res.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
if res.Tag, err = d.String(); err != nil {
return res, nil, err
}
count, err := d.Uint32()
if err != nil {
return res, nil, err
}
if count > maxOps {
return res, nil, fmt.Errorf("nfs4: %d results exceeds %d", count, maxOps)
}
var ops []ResOp
var bodies [][]byte
for range count {
var op ResOp
if op.Op, err = d.Uint32(); err != nil {
return res, nil, err
}
if op.Status, err = d.Uint32(); err != nil {
return res, nil, err
}
start := len(payload) - d.Remaining()
n, err := resBodyLenCB(op.Op, op.Status, payload[start:])
if err != nil {
return res, nil, err
}
if n < 0 || start+n > len(payload) {
return res, nil, fmt.Errorf("nfs4: result body of %d bytes overruns the payload", n)
}
op.Body = payload[start : start+n]
if _, err := d.Raw(n); err != nil {
return res, nil, err
}
ops = append(ops, op)
bodies = append(bodies, op.Body)
}
if d.Remaining() > 0 {
return res, nil, fmt.Errorf("nfs4: %d bytes trail the declared operations", d.Remaining())
}
res.Ops = ops
return res, bodies, nil
}
+256
View File
@@ -0,0 +1,256 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Package nfs4 carries the NFSv4.2 wire vocabulary: the program and
// operation numbers, the error codes, the attribute table, and the
// encoding of the COMPOUND procedure and its operations.
//
// The constants follow RFC 8881 and RFC 7862. Only operations implemented
// here are named; the dispatcher answers everything else with
// NFS4ERR_NOT_SUPP.
package nfs4
// The NFS program of RFC 8881. Version 4 is the only RPC program version
// the NFSv4 family uses; the minor version travels inside COMPOUND.
const (
Program = 100003
Version = 4
MinorVersion = 2
ProcNull = 0
ProcCompound = 1
)
// Operation numbers, RFC 8881 section 15. Operations 1 and 2 are unassigned.
const (
OpAccess = 3
OpClose = 4
OpCommit = 5
OpCreate = 6
OpDelegPurge = 7
OpDelegReturn = 8
OpGetattr = 9
OpGetfh = 10
OpLink = 11
OpLock = 12
OpLockt = 13
OpLocku = 14
OpLookup = 15
OpLookupp = 16
OpNverify = 17
OpOpen = 18
OpOpenattr = 19
OpOpenConfirm = 20
OpOpenDowngrade = 21
OpPutfh = 22
OpPutPubfh = 23
OpPutRootfh = 24
OpRead = 25
OpReaddir = 26
OpReadlink = 27
OpRemove = 28
OpRename = 29
OpRenew = 30
OpRestorefh = 31
OpSavefh = 32
OpSecinfo = 33
OpSetattr = 34
OpSetClientID = 35
OpSetClientIDConfirm = 36
OpVerify = 37
OpWrite = 38
OpReleaseLockOwner = 39
OpBackchannelCtl = 40
OpBindConnToSession = 41
OpExchangeID = 42
OpCreateSession = 43
OpDestroySession = 44
OpFreeStateid = 45
OpGetDirDelegation = 46
OpGetDeviceInfo = 47
OpGetDeviceList = 48
OpLayoutCommit = 49
OpLayoutGet = 50
OpLayoutReturn = 51
OpSecinfoNoName = 52
OpSequence = 53
OpSetSsv = 54
OpTestStateid = 55
OpWantDelegation = 56
OpDestroyClientID = 57
OpReclaimComplete = 58
OpAllocate = 59
OpCopy = 60
OpCopyNotify = 61
OpDeallocate = 62
OpIoAdvise = 63
OpLayoutError = 64
OpLayoutStats = 65
OpOffloadCancel = 66
OpOffloadStatus = 67
OpReadPlus = 68
OpSeek = 69
OpWriteSame = 70
OpClone = 71
OpGetxattr = 72
OpSetxattr = 73
OpListxattr = 74
OpRemovexattr = 75
OpIllegal = 10044
)
// NFS4ERR statuses. Every number here was verified against the error
// table of RFC 8881 section 15.1; several plausible looking values are
// wrong by a few dozen, which real clients answer with a mismatch.
const (
ErrOK = 0
ErrPerm = 1
ErrNoEnt = 2
ErrIO = 5
ErrNXIO = 6
ErrAccess = 13
ErrExist = 17
ErrXDev = 18
ErrNotDir = 20
ErrIsDir = 21
ErrInval = 22
ErrFBig = 27
ErrNoSpc = 28
ErrROFS = 30
ErrNameTooLong = 63
ErrNotEmpty = 66
ErrDQuot = 69
ErrStale = 70
ErrBadHandle = 10001
ErrNotSupp = 10004
ErrTooSmall = 10005
ErrServerFault = 10006
ErrBadType = 10007
ErrDelay = 10008
ErrSame = 10009
ErrDenied = 10010
ErrExpired = 10011
ErrLocked = 10012
ErrGrace = 10013
ErrNoGrace = 10033
ErrLocksHeld = 10037
ErrFHExpired = 10014
ErrShareDenied = 10015
ErrWrongSec = 10016
ErrClidInUse = 10017
ErrResource = 10018
ErrMoved = 10019
ErrNoFileHandle = 10020
ErrMinorVersMismatch = 10021
ErrStaleClientID = 10022
ErrOldStateid = 10024
ErrBadStateid = 10025
ErrBadSeqid = 10026
ErrNotSame = 10027
ErrAttrNotSupp = 10032
ErrDeadLock = 10045
ErrFileOpen = 10046
ErrOpIllegal = 10044
ErrBadName = 10041
ErrBadSession = 10052
ErrBadSlot = 10053
ErrSequencePos = 10064
ErrCompleteAlready = 10054
ErrConnNotBound = 10055
ErrNotOnlyOp = 10081
ErrOpNotInSession = 10071
ErrSeqMisordered = 10063
ErrRetryUncachedRep = 10068
ErrClientIDBusy = 10074
ErrDeadSession = 10078
ErrBadIOMode = 10049
ErrBadLayout = 10050
ErrNoMatchingLayout = 10060
ErrUnknownLayoutType = 10062
ErrPnfsNoLayout = 10080
ErrNoXattr = 10095
ErrXattr2Big = 10096
ErrWrongType = 10083
ErrBadOwner = 10093
)
// Layout return kinds of the layoutreturn4 returntype switch, RFC 8881
// section 18.44.
const (
ReturnFile = 1
ReturnFsid = 2
ReturnAll = 3
)
// File types of the fattr4 type attribute.
const (
NF4Reg = 1
NF4Dir = 2
NF4Blk = 3
NF4Chr = 4
NF4Lnk = 5
NF4Sock = 6
NF4Fifo = 7
)
// Access mask bits, RFC 8881 section 15.2.2.
const (
AccessRead = 1 << 0
AccessLookup = 1 << 1
AccessModify = 1 << 2
AccessExtend = 1 << 3
AccessDelete = 1 << 4
AccessExec = 1 << 5
)
// Write stability levels, RFC 8881 section 15.8. The server always answers
// FILE_SYNC, so the client needs no replay bookkeeping.
const (
StableUnstable = 0
StableDataSync = 1
StableFileSync = 2
)
// CREATE modes of the createhow4 union.
const (
CreateUnchecked = 0
CreateGuarded = 1
CreateExclusive = 2
CreateExclusive1 = 3 // EXCLUSIVE4_1 of RFC 8881
)
// File handle expiry classes. This server serves handles that survive as
// long as the server process does.
const (
FH4Persistent = 0
FH4VolatileAny = 1
FH4VolatileMigrate = 2
FH4VolatileNoMigrate = 3
)
// A Stateid is the 16 byte state identifier of RFC 8881 section 8.2: a
// 32 bit sequence number followed by a 12 byte other field. The all zero
// stateid is the anonymous stateid.
type Stateid [16]byte
// AllZero is the anonymous stateid: no lock state applies to the request.
var AllZero Stateid
// Append appends the stateid to b in wire order.
func (s Stateid) Append(b []byte) []byte {
return append(b, s[:]...)
}
// Security flavours a SECINFO answer names, the AUTH values of RFC 5531.
const (
SecFlavorNone = 0
SecFlavorSys = 1
SecFlavorGSS = 6
)
// The secinfo_style4 styles of RFC 8881 section 18.44: the current file
// handle, or its parent directory.
const (
StyleCurrentFH = 0 // SECINFO_STYLE4_CURRENT_FH
StyleParent = 1 // SECINFO_STYLE4_PARENT
)
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// The numbered session id: the prefix, the number and the client id
// halves, RFC 8881 section 18.36.
func TestMakeNumberedSessionID(t *testing.T) {
id := MakeNumberedSessionID([4]byte{9, 9, 9, 9}, 0x11223344, 0x5566778899aabbcc)
if id[0] != 9 || id[3] != 9 {
t.Fatalf("prefix % x", id[:4])
}
if id[4] != 0x11 || id[7] != 0x44 {
t.Fatalf("number % x", id[4:8])
}
if id.ClientIDOf() != 0x5566778899aabbcc {
t.Fatalf("clientid %x", id.ClientIDOf())
}
// Every number mints a distinct id.
if MakeNumberedSessionID([4]byte{}, 1, 5) == MakeNumberedSessionID([4]byte{}, 2, 5) {
t.Fatal("two numbers minted the same session id")
}
}
// NegotiateChannel clamps each value to the smaller of the request and
// the limit and leaves a zero request at zero.
func TestNegotiateChannel(t *testing.T) {
got := NegotiateChannel(
ChannelAttrs{MaxRequest: 1 << 30, MaxResponse: 1 << 10, MaxRespResourced: 1 << 10,
MaxOperations: 64, MaxRequests: 0},
ChannelAttrs{MaxRequest: 1 << 22, MaxResponse: 1 << 22, MaxRespResourced: 1 << 22,
MaxOperations: 16, MaxRequests: 8},
)
want := ChannelAttrs{MaxRequest: 1 << 22, MaxResponse: 1 << 10, MaxRespResourced: 1 << 10,
MaxOperations: 16, MaxRequests: 0}
if got.MaxRequest != want.MaxRequest || got.MaxResponse != want.MaxResponse ||
got.MaxRespResourced != want.MaxRespResourced || got.MaxOperations != want.MaxOperations ||
got.MaxRequests != want.MaxRequests {
t.Fatalf("negotiated %+v, want %+v", got, want)
}
}
// The EXCLUSIVE4_1 open args round trip: guarded create, verifier
// consumed, mode carried.
func TestOpenArgsExclusive41RoundTrip(t *testing.T) {
arg := AppendOpenArgsExclusive41(nil, 0x77, []byte("ex"),
[8]byte{1, 2, 3, 4, 5, 6, 7, 8}, 0o640, "ex41.txt")
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpOpen {
t.Fatalf("op %d %v", op, err)
}
a, err := DecodeOpenArgs(d)
if err != nil {
t.Fatal(err)
}
if !a.Create || !a.Guarded || a.Truncate || a.Perm != 0o640 || a.Name != "ex41.txt" ||
a.ClientID != 0x77 || string(a.Owner) != "ex" {
t.Fatalf("args %+v", a)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left", d.Remaining())
}
// The guarded form sets the flag the same way.
arg = AppendOpenArgs(nil, 1, []byte("o"), ShareAccessRead, 0, true, 0o600, "g.txt")
if _, err := DecodeOpenArgs(xdr.NewDecoder(arg[4:])); err != nil {
t.Fatal(err)
}
}
// The OPEN result measurement walks the write delegation with its space
// limit union and the permissions ace.
func TestResBodyLenOpenDelegations(t *testing.T) {
var st Stateid
st[0] = 1
write := AppendOpenResDeleg(nil, st, OpenDelegWrite, st)
if n, err := resBodyLen(OpOpen, ErrOK, write); err != nil || n != len(write) {
t.Fatalf("write deleg measured %d of %d: %v", n, len(write), err)
}
read := AppendOpenResDeleg(nil, st, OpenDelegRead, st)
if n, err := resBodyLen(OpOpen, ErrOK, read); err != nil || n != len(read) {
t.Fatalf("read deleg measured %d of %d: %v", n, len(read), err)
}
none := AppendOpenRes(nil, st)
if n, err := resBodyLen(OpOpen, ErrOK, none); err != nil || n != len(none) {
t.Fatalf("no deleg measured %d of %d: %v", n, len(none), err)
}
// A truncated body of any of the three fails instead of measuring
// past the payload.
for n := 0; n < len(write); n += 4 {
if _, err := resBodyLen(OpOpen, ErrOK, write[:n]); err == nil {
t.Fatalf("a %d byte prefix measured cleanly", n)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Directory delegations and the notification family: the wire codecs of
// GET_DIR_DELEGATION (RFC 8881 section 18.39), CB_NOTIFY and
// CB_NOTIFY_LOCK (RFC 8881 section 20), with the shapes taken from the
// XDR of RFC 7863.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Notification types of the notify_type4 enum.
const (
NotifyChangeChildAttrs = 0
NotifyChangeDirAttrs = 1
NotifyRemoveEntry = 2
NotifyAddEntry = 3
NotifyRenameEntry = 4
NotifyChangeCookieVerf = 5
)
// Statuses of the gddrnf4_status union.
const (
GddOK = 0
GddUnavail = 1
)
// A Notify4 is one notify4: the kind of the change and the encoded
// change body.
type Notify4 struct {
Mask Bitmap
Vals []byte
}
// A CBNotify is the decoded CB_NOTIFY4args.
type CBNotify struct {
Stateid Stateid
FH []byte
Changes []Notify4
}
// A CBNotifyLock is the decoded CB_NOTIFY_LOCK4args.
type CBNotifyLock struct {
FH []byte
Clientid uint64
Owner []byte
}
// AppendGetDirDelegationArgs encodes the GET_DIR_DELEGATION4args: the
// signal flag, the notification types, the attr delay hints and the
// requested attribute bitmaps.
func AppendGetDirDelegationArgs(b []byte, signalAvail bool, notifyTypes Bitmap, childDelay, dirDelay NfsTime, childAttrs, dirAttrs Bitmap) []byte {
b = xdr.AppendUint32(b, OpGetDirDelegation)
b = xdr.AppendBool(b, signalAvail)
b = notifyTypes.AppendTo(b)
b = appendNfsTime(b, childDelay)
b = appendNfsTime(b, dirDelay)
b = childAttrs.AppendTo(b)
return dirAttrs.AppendTo(b)
}
// AppendGetDirDelegationResOK encodes the delegation granted: the
// verifier, the stateid, the supported notifications and the attribute
// bitmaps the server promises.
func AppendGetDirDelegationResOK(b []byte, cookieverf [8]byte, st Stateid, notification, childAttrs, dirAttrs Bitmap) []byte {
b = xdr.AppendUint32(b, GddOK)
b = append(b, cookieverf[:]...)
b = st.Append(b)
b = notification.AppendTo(b)
b = childAttrs.AppendTo(b)
return dirAttrs.AppendTo(b)
}
// AppendGetDirDelegationResUnavail encodes the no delegation answer: the
// server promises to signal when one becomes available.
func AppendGetDirDelegationResUnavail(b []byte, willSignal bool) []byte {
b = xdr.AppendUint32(b, GddUnavail)
return xdr.AppendBool(b, willSignal)
}
// appendNotifyEntry encodes one notify_entry4: the name and the full
// attribute list, which this build carries empty.
func appendNotifyEntry(b []byte, name string) []byte {
b = xdr.AppendString(b, name)
return AppendFattr(b, Bitmap{}, Attrs{})
}
// AppendNotifyAdd encodes the notify_add4 body: the optional removed
// source of a rename, the new entry, its cookie, the optional previous
// entry and the end of page flag.
func AppendNotifyAdd(b []byte, name string, cookie uint64, lastEntry bool) []byte {
b = xdr.AppendUint32(b, 0) // nad_old_entry<1>: none
b = appendNotifyEntry(b, name)
b = xdr.AppendUint32(b, 0) // nad_new_entry_cookie<1>: none
b = xdr.AppendUint32(b, 0) // nad_prev_entry<1>: none
return xdr.AppendBool(b, lastEntry)
}
// AppendNotifyRemove encodes the notify_remove4 body: the removed entry
// and the cookie the client resumes past it from.
func AppendNotifyRemove(b []byte, name string, cookie uint64) []byte {
b = appendNotifyEntry(b, name)
return xdr.AppendUint64(b, cookie)
}
// AppendNotify4 encodes one notify4: the mask and the encoded body.
func AppendNotify4(b []byte, mask Bitmap, vals []byte) []byte {
b = mask.AppendTo(b)
return xdr.AppendVarOpaque(b, vals)
}
// AppendCBNotifyArgs encodes the CB_NOTIFY4args: the delegation stateid,
// the directory and the changes.
func AppendCBNotifyArgs(b []byte, st Stateid, fh []byte, changes []Notify4) []byte {
b = xdr.AppendUint32(b, OpCBNotify)
b = st.Append(b)
b = xdr.AppendVarOpaque(b, fh)
b = xdr.AppendUint32(b, uint32(len(changes)))
for _, c := range changes {
b = AppendNotify4(b, c.Mask, c.Vals)
}
return b
}
// DecodeCBNotifyArgs decodes the CB_NOTIFY4args the client receives.
func DecodeCBNotifyArgs(d *xdr.Decoder) (CBNotify, error) {
var out CBNotify
raw, err := d.Raw(16)
if err != nil {
return out, err
}
copy(out.Stateid[:], raw)
if out.FH, err = d.VarOpaque(); err != nil {
return out, err
}
n, err := d.Uint32()
if err != nil {
return out, err
}
for range n {
var c Notify4
if c.Mask, err = ReadBitmap(d); err != nil {
return out, err
}
if c.Vals, err = d.VarOpaque(); err != nil {
return out, err
}
out.Changes = append(out.Changes, c)
}
return out, nil
}
// AppendCBNotifyLockArgs encodes the CB_NOTIFY_LOCK4args: the file and
// the lock owner whose denied lock may be available again.
func AppendCBNotifyLockArgs(b []byte, fh []byte, clientid uint64, owner []byte) []byte {
b = xdr.AppendUint32(b, OpCBNotifyLock)
b = xdr.AppendVarOpaque(b, fh)
b = xdr.AppendUint64(b, clientid)
return xdr.AppendVarOpaque(b, owner)
}
// DecodeCBNotifyLockArgs decodes the CB_NOTIFY_LOCK4args the client
// receives.
func DecodeCBNotifyLockArgs(d *xdr.Decoder) (CBNotifyLock, error) {
var out CBNotifyLock
fh, err := d.VarOpaque()
if err != nil {
return out, err
}
out.FH = fh
if out.Clientid, err = d.Uint64(); err != nil {
return out, err
}
out.Owner, err = d.VarOpaque()
return out, err
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestDirDelegationShapes(t *testing.T) {
t.Parallel()
st := Stateid{1, 'D', 'D', 'E'}
args := AppendGetDirDelegationArgs(nil, true, OfBits(NotifyAddEntry),
NfsTime{Seconds: 5, Nseconds: 6}, NfsTime{Seconds: 7, Nseconds: 8},
Bitmap{}, Bitmap{})
d := xdr.NewDecoder(args[4:])
if sig, err := d.Bool(); err != nil || !sig {
t.Fatalf("signal %v: %v", sig, err)
}
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
for _, want := range []NfsTime{{Seconds: 5, Nseconds: 6}, {Seconds: 7, Nseconds: 8}} {
s, err := d.Int64()
if err != nil || s != want.Seconds {
t.Fatalf("delay seconds %d: %v", s, err)
}
ns, err := d.Uint32()
if err != nil || ns != want.Nseconds {
t.Fatalf("delay nseconds %d: %v", ns, err)
}
}
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the dir delegation args", d.Remaining())
}
ok := AppendGetDirDelegationResOK(nil, [8]byte{9}, st,
OfBits(NotifyAddEntry), Bitmap{}, Bitmap{})
n, err := resBodyLen(OpGetDirDelegation, ErrOK, ok)
if err != nil || n != len(ok) {
t.Fatalf("resok measured %d of %d: %v", n, len(ok), err)
}
un := AppendGetDirDelegationResUnavail(nil, true)
if n, err := resBodyLen(OpGetDirDelegation, ErrOK, un); err != nil || n != len(un) {
t.Fatalf("unavail measured %d of %d: %v", n, len(un), err)
}
d = xdr.NewDecoder(un)
if s, err := d.Uint32(); err != nil || s != GddUnavail {
t.Fatalf("unavail status %d: %v", s, err)
}
}
func TestNotifyShapes(t *testing.T) {
t.Parallel()
add := AppendNotifyAdd(nil, "entry", 0, true)
d := xdr.NewDecoder(add)
if n, err := d.Uint32(); err != nil || n != 0 {
t.Fatalf("old entries %d: %v", n, err)
}
if name, err := d.String(); err != nil || name != "entry" {
t.Fatalf("name %q: %v", name, err)
}
if _, err := ReadBitmap(d); err != nil { // empty fattr bitmap
t.Fatal(err)
}
if _, err := d.VarOpaque(); err != nil { // empty fattr blob
t.Fatal(err)
}
for range 2 {
if _, err := d.Uint32(); err != nil {
t.Fatal(err)
}
}
if last, err := d.Bool(); err != nil || !last {
t.Fatalf("last %v: %v", last, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the add body", d.Remaining())
}
rem := AppendNotifyRemove(nil, "gone", 7)
d = xdr.NewDecoder(rem)
if name, err := d.String(); err != nil || name != "gone" {
t.Fatalf("name %q: %v", name, err)
}
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if _, err := d.VarOpaque(); err != nil {
t.Fatal(err)
}
if c, err := d.Uint64(); err != nil || c != 7 {
t.Fatalf("cookie %d: %v", c, err)
}
// CB_NOTIFY args round trip through the decoder the client uses.
st := Stateid{2, 'N'}
fh := []byte("dir-fh")
changes := []Notify4{{Mask: OfBits(NotifyAddEntry), Vals: add}}
cb := AppendCBNotifyArgs(nil, st, fh, changes)
if n, err := resBodyLenCB(OpCBSequence, ErrOK, make([]byte, 32)); err != nil {
t.Fatal(err)
} else if n != 32 {
t.Fatalf("cb sequence body %d", n)
}
got, err := DecodeCBNotifyArgs(xdr.NewDecoder(cb[4:]))
if err != nil {
t.Fatal(err)
}
if got.Stateid != st || !bytes.Equal(got.FH, fh) || len(got.Changes) != 1 {
t.Fatalf("decoded %+v", got)
}
if !got.Changes[0].Mask.Has(NotifyAddEntry) {
t.Fatalf("mask %v", got.Changes[0].Mask)
}
// CB_NOTIFY_LOCK args round trip.
nl := AppendCBNotifyLockArgs(nil, fh, 0x77, []byte("owner"))
lock, err := DecodeCBNotifyLockArgs(xdr.NewDecoder(nl[4:]))
if err != nil {
t.Fatal(err)
}
if lock.Clientid != 0x77 || string(lock.Owner) != "owner" || !bytes.Equal(lock.FH, fh) {
t.Fatalf("decoded %+v", lock)
}
if n, err := resBodyLenCB(OpCBNotifyLock, ErrOK, nil); err != nil || n != 0 {
t.Fatalf("notify lock res body %d: %v", n, err)
}
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// FuzzDecodeCompound feeds arbitrary compound payloads through the
// argument and result walkers: no input may panic, and every malformed
// body must arrive as an error, never as a wrong split.
func FuzzDecodeCompound(f *testing.F) {
args := AppendCompoundArgs(nil, "seed", MinorVersion, [][]byte{
AppendSequenceArgs(nil, SessionID{1}, 1, 0, 0, true),
AppendPutRootfh(nil),
AppendLookup(nil, "a"),
})
f.Add(args)
res := AppendCompoundRes(nil, ErrOK, "seed", [][]byte{
append(AppendOpHeader(nil, OpSequence, ErrOK), AppendSequenceRes(nil, SessionID{1}, 1, 0, 0, 0)...),
AppendOpHeader(nil, OpPutRootfh, ErrOK),
})
f.Add(res)
f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 75, 0, 0, 0, 0})
f.Add([]byte{0, 0, 0, 9, 0, 0, 0, 1, 0, 0, 0, 1})
f.Fuzz(func(t *testing.T, data []byte) {
// The property under test is that none of this panics; a
// malformed body is an ordinary error and a well formed one
// decodes with bodies that stay inside the payload.
_, d, err := DecodeCompoundArgs(data)
if err == nil {
for {
op, oerr := d.Uint32()
if oerr != nil || walkArgs(op, d) {
break
}
}
}
_, _, _ = DecodeCompoundResBodies(data)
_, _, _ = DecodeCompoundResBodiesCB(data)
})
}
// walkArgs walks one operation's arguments the way the dispatcher does,
// so the fuzzed stream exercises the real decoders. The boolean reports
// that the stream ended or was refused.
func walkArgs(op uint32, d *xdr.Decoder) bool {
switch op {
case OpExchangeID:
_, err := DecodeExchangeIDArgs(d)
return err != nil
case OpCreateSession:
_, err := DecodeCreateSessionArgs(d)
return err != nil
case OpSequence:
_, err := DecodeSequenceArgs(d)
return err != nil
case OpSecinfoNoName:
_, err := DecodeSecinfoNoNameArgs(d)
return err != nil
}
_, err := d.Uint32()
return err != nil
}
// TestRegistryValues pins the registry numbers this package speaks
// against the standards: an accidental renumbering of any of these
// breaks interoperation with every conformant peer, so the values
// themselves are the contract.
func TestRegistryValues(t *testing.T) {
pins := []struct {
name string
got uint32
want uint32
rfc string
}{
{"session id size", uint32(len(SessionID{})), 16, "RFC 7863"},
{"FATTR4_TIME_ACCESS_SET", AttrTimeAccessSet, 48, "RFC 7863"},
{"FATTR4_TIME_MODIFY_SET", AttrTimeModifySet, 54, "RFC 7863"},
{"LAYOUT4_FLEX_FILES", LayoutTypeFlexfiles, 4, "RFC 8435"},
{"LAYOUT4_NFSV4_1_FILES", LayoutTypeFiles, 1, "RFC 7863"},
{"LAYOUT4_OSD2_OBJECTS", LayoutTypeObjects, 2, "RFC 7863"},
{"LAYOUT4_BLOCK_VOLUME", LayoutTypeBlock, 3, "RFC 7863"},
{"layoutiomode4 RW", IoModeRW, 2, "RFC 7863"},
{"layoutiomode4 ANY", IoModeAny, 3, "RFC 7863"},
{"SP4_MACH_CRED", StateProtectMachCred, 1, "RFC 8881"},
{"SP4_SSV", StateProtectSSV, 2, "RFC 8881"},
{"SECINFO_STYLE4_CURRENT_FH", StyleCurrentFH, 0, "RFC 8881"},
{"SECINFO_STYLE4_PARENT", StyleParent, 1, "RFC 8881"},
{"NFS4ERR_NXIO", ErrNXIO, 6, "RFC 8881"},
{"NFS4ERR_BADOWNER", ErrBadOwner, 10093, "RFC 8881"},
{"NFS4ERR_NOXATTR", ErrNoXattr, 10095, "RFC 8276"},
{"NFS4ERR_XATTR2BIG", ErrXattr2Big, 10096, "RFC 8276"},
}
for _, p := range pins {
if p.got != p.want {
t.Errorf("%s: got %d, want %d per %s", p.name, p.got, p.want, p.rfc)
}
}
}
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The layout type families beyond flexfiles, each with its wire body
// and the device address this build answers for it: files (RFC 5661
// section 13.3), block/volume (RFC 5663), objects (RFC 5664) and SCSI
// (RFC 8154). The metadata server of this build emulates every one of
// them over its own storage, so all bodies name the one device the
// server itself is.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// AppendFileLayoutBody encodes the nfsv4_1_file_layout4: the device,
// the stripe unit descriptor, the first stripe index, the pattern
// offset and the file handle list.
func AppendFileLayoutBody(b []byte, device [16]byte, util, firstStripe uint32, patternOffset uint64, fhs [][]byte) []byte {
b = append(b, device[:]...)
b = xdr.AppendUint32(b, util)
b = xdr.AppendUint32(b, firstStripe)
b = xdr.AppendUint64(b, patternOffset)
b = xdr.AppendUint32(b, uint32(len(fhs)))
for _, fh := range fhs {
b = xdr.AppendVarOpaque(b, fh)
}
return b
}
// AppendFileDeviceAddr encodes the nfsv4_1_file_layout_ds_addr4: the
// stripe indices over the multipath list of data server addresses.
func AppendFileDeviceAddr(b []byte, stripeIndices []uint32, addrs []NetAddr) []byte {
b = xdr.AppendUint32(b, uint32(len(stripeIndices)))
for _, i := range stripeIndices {
b = xdr.AppendUint32(b, i)
}
b = xdr.AppendUint32(b, uint32(len(addrs)))
for _, a := range addrs {
b = xdr.AppendString(b, a.Netid)
b = xdr.AppendString(b, a.Uaddr)
}
return b
}
// A BlockVolume is one pnfs_block_volume4 of the emulated device: a
// simple volume with the given base offset and block count.
type BlockVolume struct {
// Type is PNFS_BLOCK_VOLUME_SIMPLE (1).
BaseOffset uint64
BlockCount uint64
DeviceID [16]byte
}
// AppendBlockDeviceAddr encodes the pnfs_block_deviceaddr4 with one
// simple volume, RFC 5663 section 5.1.
func AppendBlockDeviceAddr(b []byte, v BlockVolume) []byte {
b = xdr.AppendUint32(b, 1) // one volume
b = xdr.AppendUint32(b, 1) // PNFS_BLOCK_VOLUME_SIMPLE
b = append(b, v.DeviceID[:]...)
b = xdr.AppendUint64(b, v.BaseOffset)
return xdr.AppendUint64(b, v.BlockCount)
}
// AppendScsiLayoutBody encodes the pnfs_scsi_layout4: one extent over
// the emulated volume, RFC 8154 section 5.2.
func AppendScsiLayoutBody(b []byte, device [16]byte, fileOffset, length, storageOffset uint64) []byte {
b = xdr.AppendUint32(b, 1) // one extent
b = append(b, device[:]...)
b = xdr.AppendUint64(b, fileOffset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendUint64(b, storageOffset)
return xdr.AppendUint32(b, 0) // reserved
}
// An ObjectLayout is the emulated pnfs_osd_layout4: the map header and
// one component credential over the emulated OSD.
type ObjectLayout struct {
NumComponents uint32
StripeUnit uint64
GroupWidth uint32
GroupDepth uint32
RefTagSize uint32
}
// AppendObjectLayoutBody encodes a minimal pnfs_osd_layout4 for the
// emulated OSD, RFC 5664 section 3.3: the data map and one component.
func AppendObjectLayoutBody(b []byte, device [16]byte, l ObjectLayout) []byte {
// pnfs_osd_data_map4
b = xdr.AppendUint32(b, 0) // odi_odm_raid_algorithm: PNFS_OSD_RAID_0
b = xdr.AppendUint64(b, l.StripeUnit)
b = xdr.AppendUint32(b, l.NumComponents)
b = xdr.AppendUint32(b, l.GroupWidth)
b = xdr.AppendUint32(b, l.GroupDepth)
b = xdr.AppendUint32(b, l.RefTagSize)
// olo_comps_index and the single component
b = xdr.AppendUint32(b, 0)
// pnfs_osd_object_cred4: version, credential type, opaque cred
b = xdr.AppendUint32(b, 0)
b = xdr.AppendUint32(b, 0)
b = xdr.AppendVarOpaque(b, nil)
// object id: partition id + object id
b = xdr.AppendUint64(b, 0)
b = xdr.AppendUint64(b, 1)
_ = device
return b
}
// AppendGetDeviceListRes encodes the GETDEVICELIST4resok: the cookie,
// its verifier, the device ids and the end of list flag, RFC 5661
// section 18.41.
func AppendGetDeviceListRes(b []byte, cookie uint64, cookieverf [8]byte, devices [][16]byte, eof bool) []byte {
b = xdr.AppendUint64(b, cookie)
b = append(b, cookieverf[:]...)
b = xdr.AppendUint32(b, uint32(len(devices)))
for _, d := range devices {
b = append(b, d[:]...)
}
return xdr.AppendBool(b, eof)
}
// AppendGetDeviceListArgs encodes the GETDEVICELIST argop: the layout
// type, the device count budget and the resumption cookie, RFC 5661
// section 18.41.
func AppendGetDeviceListArgs(b []byte, layoutType, maxDevices uint32, cookie uint64, cookieverf [8]byte) []byte {
b = xdr.AppendUint32(b, OpGetDeviceList)
b = xdr.AppendUint32(b, layoutType)
b = xdr.AppendUint32(b, maxDevices)
b = xdr.AppendUint64(b, cookie)
return append(b, cookieverf[:]...)
}
// LayoutTypeFlexFilesV2 is the layout type of the flexible file layout
// version 2: the layout type number 0x6 of the pNFS Layout Types
// registry, defined by the expired Internet-Draft
// draft-haynes-nfsv4-flex-filesv2-00. The draft never became an RFC;
// the number is stable and Linux clients carry the same one.
const LayoutTypeFlexFilesV2 = 6
// A FlexDataServerV2 is one ffv2_data_server4 of the draft: like the
// version one entry but with an array of stateids, the file handles
// per supported version, and the RPC credential the data server needs
// for loose coupling.
type FlexDataServerV2 struct {
DeviceID [16]byte
Efficiency uint32
Stateids []Stateid
FHs [][]byte
User string
Group string
AuthFlavor uint32
AuthBody []byte
}
// A FlexMirrorV2 is one ffv2_mirror4: the data servers of one mirror.
type FlexMirrorV2 struct {
DataServers []FlexDataServerV2
}
// AppendFlexFileLayoutBodyV2 encodes the ffv2_layout4 of
// draft-haynes-nfsv4-flex-filesv2-00 section 3.1: the stripe unit, the
// mirrors with their data servers, the flags and the stats hint. The
// device address is the version one ff_device_addr4, whose version
// array must carry one entry per stateid and per file handle.
func AppendFlexFileLayoutBodyV2(b []byte, flags uint32, stripeUnit uint64, mirrors []FlexMirrorV2) []byte {
b = xdr.AppendUint64(b, stripeUnit)
b = xdr.AppendUint32(b, uint32(len(mirrors)))
for _, m := range mirrors {
b = xdr.AppendUint32(b, uint32(len(m.DataServers)))
for _, ds := range m.DataServers {
b = append(b, ds.DeviceID[:]...)
b = xdr.AppendUint32(b, ds.Efficiency)
b = xdr.AppendUint32(b, uint32(len(ds.Stateids)))
for _, st := range ds.Stateids {
b = st.Append(b)
}
b = xdr.AppendUint32(b, uint32(len(ds.FHs)))
for _, fh := range ds.FHs {
b = xdr.AppendVarOpaque(b, fh)
}
b = xdr.AppendString(b, ds.User)
b = xdr.AppendString(b, ds.Group)
// opaque_auth: the flavour and the credential body.
b = xdr.AppendUint32(b, ds.AuthFlavor)
b = xdr.AppendVarOpaque(b, ds.AuthBody)
}
}
b = xdr.AppendUint32(b, flags)
return xdr.AppendUint32(b, 0) // stats collect hint
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The byte range locking layer of RFC 8881 sections 15.10 to 15.13 and
// 18.11 to 18.13: LOCK, LOCKT and LOCKU with their share of unions.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Lock types of the nfs_lock_type4 enum.
const (
LockTypeRead = 1
LockTypeWrite = 2
)
// AppendLockArgsNew encodes the LOCK4args for the first LOCK of a new
// lock owner: the locker union carries the open stateid the lock hangs
// from and the identity of the lock owner.
func AppendLockArgsNew(b []byte, openStateid Stateid, lockClientid uint64, lockOwner []byte, lockType uint32, reclaim bool, offset, length uint64) []byte {
b = xdr.AppendUint32(b, OpLock)
b = xdr.AppendUint32(b, lockType)
b = xdr.AppendBool(b, reclaim)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendBool(b, true) // new_lock_owner
b = xdr.AppendUint32(b, 0) // open_seqid, deprecated
b = openStateid.Append(b)
b = xdr.AppendUint32(b, 0) // lock_seqid, deprecated
b = xdr.AppendUint64(b, lockClientid)
return xdr.AppendVarOpaque(b, lockOwner)
}
// AppendLockArgsExisting encodes the LOCK4args for a lock owner that
// already holds a lock stateid.
func AppendLockArgsExisting(b []byte, lockStateid Stateid, lockType uint32, offset, length uint64) []byte {
b = xdr.AppendUint32(b, OpLock)
b = xdr.AppendUint32(b, lockType)
b = xdr.AppendBool(b, reclaimMarker)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendBool(b, false) // not a new lock owner
b = lockStateid.Append(b)
return xdr.AppendUint32(b, 0) // lock_seqid, deprecated
}
// reclaimMarker keeps the bool arguments of the locker union readable.
const reclaimMarker = false
// AppendLockuArgs encodes the LOCKU argop: the release of one range of a
// lock stateid.
func AppendLockuArgs(b []byte, lockStateid Stateid, offset, length uint64) []byte {
b = xdr.AppendUint32(b, OpLocku)
b = xdr.AppendUint32(b, LockTypeWrite) // the standard echoes a type; the stateid rules
b = xdr.AppendUint32(b, 0) // seqid, deprecated
b = lockStateid.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint64(b, length)
}
// AppendLocktArgs encodes the LOCKT argop: a probe whether a lock of the
// type over the range would conflict, for the named owner.
func AppendLocktArgs(b []byte, lockType uint32, offset, length uint64, clientid uint64, owner []byte) []byte {
b = xdr.AppendUint32(b, OpLockt)
b = xdr.AppendUint32(b, lockType)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendUint64(b, clientid)
return xdr.AppendVarOpaque(b, owner)
}
// AppendLockRes encodes a successful LOCK4res: the lock stateid.
func AppendLockRes(b []byte, stateid Stateid) []byte {
return stateid.Append(b)
}
// AppendLockuRes encodes a successful LOCKU4res: the lock stateid, with
// its sequence bumped.
func AppendLockuRes(b []byte, stateid Stateid) []byte {
return stateid.Append(b)
}
// AppendLocktResOK appends a LOCKT result for the OK status, which carries
// nothing: no conflict was found.
func AppendLocktResOK(b []byte) []byte {
return b
}
// AppendLocktResDenied appends a LOCKT result body for the DENIED status:
// the conflicting lock, with its holder.
func AppendLocktResDenied(b []byte, offset, length uint64, lockType uint32, clientid uint64, owner []byte) []byte {
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendUint32(b, lockType)
b = xdr.AppendUint64(b, clientid)
return xdr.AppendVarOpaque(b, owner)
}
// A LockDenied is the conflicting lock a LOCKT answers.
type LockDenied struct {
Offset uint64
Length uint64
LockType uint32
ClientID uint64
Owner []byte
}
// DecodeLocktResBody decodes a LOCKT body. On the DENIED status it returns
// the conflicting lock; on OK the body is empty.
func DecodeLocktResBody(status uint32, body []byte) (LockDenied, error) {
if status != ErrDenied {
return LockDenied{}, nil
}
d := xdr.NewDecoder(body)
var denied LockDenied
var err error
if denied.Offset, err = d.Uint64(); err != nil {
return denied, err
}
if denied.Length, err = d.Uint64(); err != nil {
return denied, err
}
if denied.LockType, err = d.Uint32(); err != nil {
return denied, err
}
if denied.ClientID, err = d.Uint64(); err != nil {
return denied, err
}
denied.Owner, err = d.VarOpaque()
return denied, err
}
// resBodyLenLockt measures the LOCKT body: empty on OK, a full LOCK4denied
// on DENIED.
func resBodyLenLockt(status uint32, body []byte) (int, error) {
if status != ErrDenied {
return 0, nil
}
d := xdr.NewDecoder(body)
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.Uint32(); err != nil {
return 0, err
}
if _, err := d.Uint64(); err != nil {
return 0, err
}
if _, err := d.VarOpaque(); err != nil {
return 0, err
}
return len(body) - d.Remaining(), nil
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestLockuAndLocktResWire(t *testing.T) {
var st Stateid
st[4], st[5], st[6], st[7] = 'L', 'O', 'C', 'K'
arg := AppendLockuArgs(nil, st, 8, 32)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpLocku {
t.Fatalf("op: %d, %v", op, err)
}
if lt, err := d.Uint32(); err != nil || lt != LockTypeWrite {
t.Fatalf("locktype: %v", err)
}
if _, err := d.Uint32(); err != nil {
t.Fatal(err)
}
if raw, err := d.Raw(16); err != nil || raw[4] != 'L' {
t.Fatalf("stateid: %x, %v", raw, err)
}
if off, err := d.Uint64(); err != nil || off != 8 {
t.Fatalf("offset: %v", err)
}
if length, err := d.Uint64(); err != nil || length != 32 {
t.Fatalf("length: %v", err)
}
res := AppendLockuRes(nil, st)
if n, err := resBodyLen(OpLocku, ErrOK, res); err != nil || n != 16 {
t.Fatalf("locku measure: %d, %v", n, err)
}
okBody := AppendLocktResOK(nil)
if n, err := resBodyLen(OpLockt, ErrOK, okBody); err != nil || n != 0 {
t.Fatalf("lockt ok measure: %d, %v", n, err)
}
denied := AppendLocktResDenied(nil, 0, 100, LockTypeWrite, 0x1111, []byte("holder"))
if n, err := resBodyLen(OpLockt, ErrDenied, denied); err != nil || n != len(denied) {
t.Fatalf("lockt denied measure: %d of %d, %v", n, len(denied), err)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The file system location attributes of RFC 5661 section 11.10: the
// fs_locations and fs_locations_info attributes that migration and
// referrals hand to the client.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// The attribute numbers of the location family.
const (
AttrFsLocations = 24 // fs_locations4
AttrFsLocationsInfo = 67 // fs_locations_info4
)
// The general flags of fli_flags, RFC 5661 section 11.10.1.
const (
Fsli4ifVarSub = 0x1
)
// The general flags of the fls_info GFLAGS byte.
const (
Fsli4gfWritable = 0x01
Fsli4gfCurReq = 0x02
Fsli4gfAbsent = 0x04
Fsli4gfGoing = 0x08
Fsli4gfSplit = 0x10
)
// An FsLocation is one fs_location4: the servers that share a root
// path.
type FsLocation struct {
Servers []string
RootPath []string
}
// An FsServer is one fs_locations_server4 replica entry: the currency
// hint, the opaque info bytes and the server name.
type FsServer struct {
Currency int32
Info []byte
Server string
}
// An FsItem is one fs_locations_item4: the replicas of one root path.
type FsItem struct {
Entries []FsServer
RootPath []string
}
// AppendPathname encodes a pathname4: an array of components.
func AppendPathname(b []byte, components []string) []byte {
b = xdr.AppendUint32(b, uint32(len(components)))
for _, c := range components {
b = xdr.AppendString(b, c)
}
return b
}
// AppendFsLocations encodes the fs_locations4 attribute.
func AppendFsLocations(b []byte, fsRoot []string, locations []FsLocation) []byte {
b = AppendPathname(b, fsRoot)
b = xdr.AppendUint32(b, uint32(len(locations)))
for _, l := range locations {
b = xdr.AppendUint32(b, uint32(len(l.Servers)))
for _, s := range l.Servers {
b = xdr.AppendString(b, s)
}
b = AppendPathname(b, l.RootPath)
}
return b
}
// AppendFsLocationsInfo encodes the fs_locations_info4 attribute: the
// flags, the validity window, the root and the replica items.
func AppendFsLocationsInfo(b []byte, flags uint32, validFor int32, fsRoot []string, items []FsItem) []byte {
b = xdr.AppendUint32(b, flags)
b = xdr.AppendInt32(b, validFor)
b = AppendPathname(b, fsRoot)
b = xdr.AppendUint32(b, uint32(len(items)))
for _, item := range items {
b = xdr.AppendUint32(b, uint32(len(item.Entries)))
for _, e := range item.Entries {
b = xdr.AppendInt32(b, e.Currency)
b = xdr.AppendVarOpaque(b, e.Info)
b = xdr.AppendString(b, e.Server)
}
b = AppendPathname(b, item.RootPath)
}
return b
}
// DecodeFsLocations reads the fs_locations4 attribute back.
func DecodeFsLocations(payload []byte) (fsRoot []string, locations []FsLocation, err error) {
d := xdr.NewDecoder(payload)
if fsRoot, err = decodePathname(d); err != nil {
return
}
var n uint32
if n, err = d.Uint32(); err != nil {
return
}
for i := uint32(0); i < n; i++ {
var l FsLocation
var count uint32
if count, err = d.Uint32(); err != nil {
return
}
for j := uint32(0); j < count; j++ {
var s string
if s, err = d.String(); err != nil {
return
}
l.Servers = append(l.Servers, s)
}
if l.RootPath, err = decodePathname(d); err != nil {
return
}
locations = append(locations, l)
}
return
}
func decodePathname(d *xdr.Decoder) ([]string, error) {
n, err := d.Uint32()
if err != nil {
return nil, err
}
var out []string
for range n {
s, err := d.String()
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, nil
}
// AppendOpenattrArgs encodes the OPENATTR argop: the created flag.
func AppendOpenattrArgs(b []byte, created bool) []byte {
b = xdr.AppendUint32(b, OpOpenattr)
return xdr.AppendBool(b, created)
}
// AppendReadArgs encodes the READ argop.
func AppendReadArgs(b []byte, st Stateid, offset uint64, count uint32) []byte {
b = xdr.AppendUint32(b, OpRead)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint32(b, count)
}
+311
View File
@@ -0,0 +1,311 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// The location attributes round trip: the fs_locations attribute
// decodes back to the servers and root paths, and the info attribute
// carries the flags, the validity window and the replica entries.
func TestFsLocationsShapes(t *testing.T) {
blob := AppendFsLocations(nil, []string{"exports"}, []FsLocation{{
Servers: []string{"a.example", "b.example"},
RootPath: []string{"exports", "data"},
}})
root, locations, err := DecodeFsLocations(blob)
if err != nil {
t.Fatal(err)
}
if len(root) != 1 || root[0] != "exports" {
t.Fatalf("root %v", root)
}
if len(locations) != 1 || len(locations[0].Servers) != 2 ||
locations[0].Servers[1] != "b.example" ||
len(locations[0].RootPath) != 2 || locations[0].RootPath[1] != "data" {
t.Fatalf("locations %+v", locations)
}
// The fs_locations_info attribute: flags, validity, items with the
// opaque info bytes.
info := AppendFsLocationsInfo(nil, Fsli4ifVarSub, 60, []string{"exports"},
[]FsItem{{Entries: []FsServer{{
Currency: 3, Info: []byte{Fsli4gfWritable | Fsli4gfCurReq}, Server: "a.example",
}}, RootPath: []string{"data"}}})
d := xdr.NewDecoder(info)
if flags, err := d.Uint32(); err != nil || flags != Fsli4ifVarSub {
t.Fatalf("flags %d: %v", flags, err)
}
if valid, err := d.Int32(); err != nil || valid != 60 {
t.Fatalf("valid %d: %v", valid, err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("root count %d: %v", n, err)
}
if rootComponent, err := d.String(); err != nil || rootComponent != "exports" {
t.Fatalf("root component %q: %v", rootComponent, err)
}
if _, err := d.Uint32(); err != nil { // items count
t.Fatal(err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("entries %d: %v", n, err)
}
if cur, err := d.Int32(); err != nil || cur != 3 {
t.Fatalf("currency %d: %v", cur, err)
}
if info2, err := d.VarOpaque(); err != nil || len(info2) != 1 ||
info2[0]&Fsli4gfWritable == 0 || info2[0]&Fsli4gfCurReq == 0 {
t.Fatalf("info % x: %v", info2, err)
}
if s, err := d.String(); err != nil || s != "a.example" {
t.Fatalf("server %q: %v", s, err)
}
// The item closes with the root path of the replicas.
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("rootpath count %d: %v", n, err)
}
if c, err := d.String(); err != nil || c != "data" {
t.Fatalf("rootpath %q: %v", c, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left", d.Remaining())
}
}
// The emulated layout family bodies carry the device id first and the
// family payload after; the GETDEVICELIST pair round trips.
func TestLayoutFamilyShapes(t *testing.T) {
var dev [16]byte
copy(dev[:], "0123456789abcdef")
files := AppendFileLayoutBody(nil, dev, 4096, 2, 8,
[][]byte{[]byte("fh1"), []byte("fh2")})
d := xdr.NewDecoder(files)
if got, err := d.Raw(16); err != nil || !bytes.Equal(got, dev[:]) {
t.Fatalf("device % x: %v", got, err)
}
if u, err := d.Uint32(); err != nil || u != 4096 {
t.Fatalf("util %d: %v", u, err)
}
if fs, err := d.Uint32(); err != nil || fs != 2 {
t.Fatalf("first stripe %d: %v", fs, err)
}
if po, err := d.Uint64(); err != nil || po != 8 {
t.Fatalf("pattern %d: %v", po, err)
}
if n, err := d.Uint32(); err != nil || n != 2 {
t.Fatalf("fh count %d: %v", n, err)
}
if fh, err := d.VarOpaque(); err != nil || string(fh) != "fh1" {
t.Fatalf("fh %q: %v", fh, err)
}
if fh, err := d.VarOpaque(); err != nil || string(fh) != "fh2" {
t.Fatalf("fh2 %q: %v", fh, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left", d.Remaining())
}
// The device address: stripe indices and the multipath list.
addr := AppendFileDeviceAddr(nil, []uint32{0, 1},
[]NetAddr{{Netid: "tcp", Uaddr: "10.0.0.1.8.1"}})
d = xdr.NewDecoder(addr)
if n, err := d.Uint32(); err != nil || n != 2 {
t.Fatalf("indices %d: %v", n, err)
}
if _, err := d.Raw(8); err != nil {
t.Fatal(err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("addrs %d: %v", n, err)
}
if id, err := d.String(); err != nil || id != "tcp" {
t.Fatalf("netid %q: %v", id, err)
}
if ua, err := d.String(); err != nil || ua != "10.0.0.1.8.1" {
t.Fatalf("uaddr %q: %v", ua, err)
}
// The block volume, the SCSI extent and the OSD component bodies
// carry their shapes.
block := AppendBlockDeviceAddr(nil, BlockVolume{DeviceID: dev, BaseOffset: 8, BlockCount: 64})
d = xdr.NewDecoder(block)
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("volumes %d: %v", n, err)
}
if typ, err := d.Uint32(); err != nil || typ != 1 {
t.Fatalf("volume type %d: %v", typ, err)
}
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, dev[:]) {
t.Fatalf("volume device % x: %v", raw, err)
}
if bo, err := d.Uint64(); err != nil || bo != 8 {
t.Fatalf("base offset %d: %v", bo, err)
}
if bc, err := d.Uint64(); err != nil || bc != 64 {
t.Fatalf("block count %d: %v", bc, err)
}
scsi := AppendScsiLayoutBody(nil, dev, 1, 2, 3)
d = xdr.NewDecoder(scsi)
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("extents %d: %v", n, err)
}
raw, err = d.Raw(16)
if err != nil || !bytes.Equal(raw, dev[:]) {
t.Fatalf("extent device % x: %v", raw, err)
}
for _, want := range []uint64{1, 2, 3} {
if v, err := d.Uint64(); err != nil || v != want {
t.Fatalf("extent field %d: %v", v, err)
}
}
obj := AppendObjectLayoutBody(nil, dev, ObjectLayout{NumComponents: 1,
StripeUnit: 4096, GroupWidth: 1, GroupDepth: 1, RefTagSize: 4})
d = xdr.NewDecoder(obj)
if alg, err := d.Uint32(); err != nil || alg != 0 {
t.Fatalf("raid algorithm %d: %v", alg, err)
}
if su, err := d.Uint64(); err != nil || su != 4096 {
t.Fatalf("stripe unit %d: %v", su, err)
}
// The GETDEVICELIST pair round trips.
list := AppendGetDeviceListArgs(nil, LayoutTypeFlexfiles, 8, 5, [8]byte{9})
d = xdr.NewDecoder(list[4:])
if typ, err := d.Uint32(); err != nil || typ != LayoutTypeFlexfiles {
t.Fatalf("list type %d: %v", typ, err)
}
if md, err := d.Uint32(); err != nil || md != 8 {
t.Fatalf("max devices %d: %v", md, err)
}
if c, err := d.Uint64(); err != nil || c != 5 {
t.Fatalf("cookie %d: %v", c, err)
}
raw, err = d.Raw(8)
if err != nil || raw[0] != 9 {
t.Fatalf("verifier % x: %v", raw, err)
}
res := AppendGetDeviceListRes(nil, 5, [8]byte{9}, [][16]byte{dev, {1}}, true)
d = xdr.NewDecoder(res)
if c, err := d.Uint64(); err != nil || c != 5 {
t.Fatalf("res cookie %d: %v", c, err)
}
if _, err := d.Raw(8); err != nil {
t.Fatal(err)
}
if n, err := d.Uint32(); err != nil || n != 2 {
t.Fatalf("devices %d: %v", n, err)
}
if _, err := d.Raw(32); err != nil {
t.Fatal(err)
}
if eof, err := d.Bool(); err != nil || !eof {
t.Fatalf("eof %v: %v", eof, err)
}
}
// The OPENATTR and READ argop encoders carry their shapes.
func TestOpenattrReadArgs(t *testing.T) {
oa := AppendOpenattrArgs(nil, true)
d := xdr.NewDecoder(oa[4:])
if created, err := d.Bool(); err != nil || !created {
t.Fatalf("created %v: %v", created, err)
}
ra := AppendReadArgs(nil, Stateid{7}, 99, 1024)
d = xdr.NewDecoder(ra[4:])
raw, err := d.Raw(16)
if err != nil || raw[0] != 7 {
t.Fatalf("stateid % x: %v", raw, err)
}
if off, err := d.Uint64(); err != nil || off != 99 {
t.Fatalf("offset %d: %v", off, err)
}
if cnt, err := d.Uint32(); err != nil || cnt != 1024 {
t.Fatalf("count %d: %v", cnt, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left", d.Remaining())
}
}
// The flexfiles version 2 layout body of
// draft-haynes-nfsv4-flex-filesv2-00 round trips: the data server
// carries the stateid array, the file handle list and the RPC
// credential the draft adds.
func TestFlexFileLayoutV2Shape(t *testing.T) {
var dev [16]byte
copy(dev[:], "v2flexdeviceid!!")
st := Stateid{2, 'F', 'F', 'V', '2'}
body := AppendFlexFileLayoutBodyV2(nil, 3, 0, []FlexMirrorV2{{
DataServers: []FlexDataServerV2{{
DeviceID: dev,
Efficiency: 7,
Stateids: []Stateid{st},
FHs: [][]byte{[]byte("fh-v2")},
User: "petr",
Group: "nfs",
AuthFlavor: 0,
AuthBody: nil,
}},
}})
d := xdr.NewDecoder(body)
if su, err := d.Uint64(); err != nil || su != 0 {
t.Fatalf("stripe unit %d: %v", su, err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("mirrors %d: %v", n, err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("servers %d: %v", n, err)
}
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, dev[:]) {
t.Fatalf("device % x: %v", raw, err)
}
if eff, err := d.Uint32(); err != nil || eff != 7 {
t.Fatalf("efficiency %d: %v", eff, err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("stateids %d: %v", n, err)
}
raw, err = d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if n, err := d.Uint32(); err != nil || n != 1 {
t.Fatalf("fhs %d: %v", n, err)
}
if fh, err := d.VarOpaque(); err != nil || string(fh) != "fh-v2" {
t.Fatalf("fh %q: %v", fh, err)
}
if u, err := d.String(); err != nil || u != "petr" {
t.Fatalf("user %q: %v", u, err)
}
if g, err := d.String(); err != nil || g != "nfs" {
t.Fatalf("group %q: %v", g, err)
}
if flavor, err := d.Uint32(); err != nil || flavor != 0 {
t.Fatalf("auth flavor %d: %v", flavor, err)
}
if authBody, err := d.VarOpaque(); err != nil || len(authBody) != 0 {
t.Fatalf("auth body % x: %v", authBody, err)
}
if flags, err := d.Uint32(); err != nil || flags != 3 {
t.Fatalf("flags %d: %v", flags, err)
}
if hint, err := d.Uint32(); err != nil || hint != 0 {
t.Fatalf("stats hint %d: %v", hint, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left", d.Remaining())
}
}
+860
View File
@@ -0,0 +1,860 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"errors"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestBitmapBits(t *testing.T) {
m := OfBits(0, 31, 32, 55, 75)
for _, n := range []uint32{0, 31, 32, 55, 75} {
if !m.Has(n) {
t.Fatalf("bit %d is not set", n)
}
}
for _, n := range []uint32{1, 30, 33, 54, 76} {
if m.Has(n) {
t.Fatalf("bit %d is set but was never given", n)
}
}
}
func TestBitmapWireRoundTrip(t *testing.T) {
want := OfBits(1, 3, 33, 55)
d := xdr.NewDecoder(want.AppendTo(nil))
got, err := ReadBitmap(d)
if err != nil {
t.Fatalf("read: %v", err)
}
for n := range uint32(80) {
if want.Has(n) != got.Has(n) {
t.Fatalf("bit %d: %v, want %v", n, got.Has(n), want.Has(n))
}
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left over", d.Remaining())
}
}
func TestBitmapEmptyAndHuge(t *testing.T) {
if len(OfBits().AppendTo(nil)) != 4 {
t.Fatal("an empty bitmap encodes as exactly the zero word count")
}
huge := xdr.AppendUint32(nil, 101)
if _, err := ReadBitmap(xdr.NewDecoder(huge)); !errors.Is(err, xdr.ErrBadLength) {
t.Fatalf("a 101 word bitmap: %v", err)
}
}
func TestStateidWire(t *testing.T) {
b := AllZero.Append(nil)
if len(b) != 16 || !bytes.Equal(b, make([]byte, 16)) {
t.Fatalf("the anonymous stateid encodes as %x", b)
}
var s Stateid
s[0] = 0x80
if got := s.Append(nil); !bytes.Equal(got, s[:]) {
t.Fatal("a stateid encodes in its own byte order")
}
}
func TestFattrAllAdvertised(t *testing.T) {
// The whole advertised set, every field set, in both directions.
request := append(Bitmap{}, SupportedAttrs...)
want := Attrs{
Type: NF4Dir,
FHExpireType: FH4Persistent,
Change: 7,
Size: 1,
LinkSupport: true,
SymlinkSupport: true,
NamedAttr: false,
FSID: [2]uint64{9, 0},
UniqueHandles: true,
FileHandle: []byte("h"),
FileID: 3,
Mode: 0o755,
Numlinks: 2,
SpaceUsed: 4096,
TimeAccess: NfsTime{1, 2},
TimeMetadata: NfsTime{3, 4},
TimeModify: NfsTime{5, 6},
MountedOnFileID: 3,
Limits: DefaultLimits,
}
response, got, err := DecodeGetattrBody(AppendGetattrRes(nil, request, want))
if err != nil {
t.Fatalf("decode: %v", err)
}
for _, n := range []uint32{AttrSupportedAttrs, AttrType, AttrFHExpireType, AttrChange,
AttrSize, AttrLinkSupport, AttrSymlinkSupport, AttrNamedAttr, AttrFSID,
AttrUniqueHandles, AttrFileHandle, AttrFileID, AttrMaxName, AttrMaxRead,
AttrMaxWrite, AttrMode, AttrNumlinks, AttrSpaceUsed, AttrTimeAccess,
AttrTimeMetadata, AttrTimeModify, AttrMountedOnFileID} {
if !response.Has(n) {
t.Fatalf("attribute %d missing from the response bitmap", n)
}
}
if got.Type != want.Type || got.FHExpireType != want.FHExpireType || got.Change != want.Change ||
got.Size != want.Size || got.LinkSupport != want.LinkSupport ||
got.SymlinkSupport != want.SymlinkSupport || got.NamedAttr != want.NamedAttr ||
got.FSID != want.FSID || got.UniqueHandles != want.UniqueHandles ||
!bytes.Equal(got.FileHandle, want.FileHandle) || got.FileID != want.FileID ||
got.Mode != want.Mode || got.Numlinks != want.Numlinks ||
got.SpaceUsed != want.SpaceUsed || got.TimeAccess != want.TimeAccess ||
got.TimeMetadata != want.TimeMetadata || got.TimeModify != want.TimeModify ||
got.MountedOnFileID != want.MountedOnFileID || got.Limits != want.Limits {
t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, want)
}
}
func TestGetattrTruncatedBody(t *testing.T) {
// A successful GETATTR whose blob is cut short is a decoding error at
// the exact attribute, not a silent misread.
request := OfBits(AttrType, AttrSize)
full := AppendGetattrRes(nil, request, Attrs{Type: NF4Reg, Size: 5})
// The fattr4 is a bitmap then a length prefixed blob; cut the last
// byte off the whole result and the blob no longer holds the size.
truncated := full[:len(full)-1]
if _, _, err := DecodeGetattrBody(truncated); err == nil {
t.Fatal("a truncated attribute list decoded")
}
}
func TestBitmapWithAndWords(t *testing.T) {
m := OfBits(3).With(70)
if !m.Has(3) || !m.Has(70) {
t.Fatalf("With lost a bit: %v", m.Words())
}
if len(OfBits(3, 32, 64).Words()) != 3 {
t.Fatal("Words trimmed a live word")
}
if len(Bitmap{5, 0}.Words()) != 1 {
t.Fatal("Words kept a trailing zero word")
}
}
func TestCompoundResUnknownOpBody(t *testing.T) {
// A successful result of an operation this client cannot measure is a
// decoding error, not a silent misread. WANT_DELEGATION fits: this
// build neither asks for delegations nor understands their answer.
payload := AppendCompoundRes(nil, ErrOK, "", [][]byte{
AppendOpHeader(nil, OpWantDelegation, ErrOK),
})
if _, _, err := DecodeCompoundResBodies(payload); !errors.Is(err, ErrUnknownOp) {
t.Fatalf("a WANT_DELEGATION result decoded as %v", err)
}
}
func TestAccessResRoundTrip(t *testing.T) {
supported, access, err := DecodeAccessBody(AppendAccessRes(nil, 0x3f, 0x21))
if err != nil || supported != 0x3f || access != 0x21 {
t.Fatalf("access body: %x %x, %v", supported, access, err)
}
}
func TestArgopBuilders(t *testing.T) {
// Every builder writes the operation number first, then its arguments.
cases := []struct {
name string
arg []byte
op uint32
}{
{"putrootfh", AppendPutRootfh(nil), OpPutRootfh},
{"savefh", AppendSavefh(nil), OpSavefh},
{"restorefh", AppendRestorefh(nil), OpRestorefh},
{"getfh", AppendGetfh(nil), OpGetfh},
{"putfh", AppendPutfh(nil, []byte("h")), OpPutfh},
{"lookup", AppendLookup(nil, "n"), OpLookup},
{"getattr", AppendGetattr(nil, OfBits(AttrType)), OpGetattr},
{"access", AppendAccess(nil, AccessRead), OpAccess},
{"read", AppendRead(nil, AllZero, 1, 2), OpRead},
{"readdir", AppendReaddir(nil, 1, [8]byte{}, 8, 4096, OfBits(AttrType)), OpReaddir},
}
for _, tc := range cases {
d := xdr.NewDecoder(tc.arg)
op, err := d.Uint32()
if err != nil || op != tc.op {
t.Fatalf("%s: op %d, %v; want %d", tc.name, op, err, tc.op)
}
}
// Spot check the argument shapes the tests rely on.
if fh, err := xdr.NewDecoder(AppendPutfh(nil, []byte("h"))[4:]).VarOpaque(); err != nil || string(fh) != "h" {
t.Fatalf("putfh argument: %q, %v", fh, err)
}
if n, err := xdr.NewDecoder(AppendLookup(nil, "n")[4:]).String(); err != nil || n != "n" {
t.Fatalf("lookup argument: %q, %v", n, err)
}
}
func TestResBodyLen(t *testing.T) {
getattr := AppendGetattrRes(nil, OfBits(AttrType), Attrs{Type: NF4Reg})
readdir := AppendReadDirRes(nil, [8]byte{}, []DirEntryRes{{Cookie: 1, Name: "x", Attrs: Attrs{Type: NF4Reg}}}, OfBits(AttrType), true)
cases := []struct {
name string
op uint32
status uint32
body []byte
want int
}{
{"error has no body", OpGetfh, ErrNoEnt, []byte{9, 9, 9, 9}, 0},
{"putfh", OpPutfh, ErrOK, nil, 0},
{"getfh", OpGetfh, ErrOK, AppendGetfhRes(nil, []byte("handle")), 4 + 8},
{"open", OpOpen, ErrOK, AppendOpenRes(nil, AllZero), 16 + 20 + 4 + 4 + 4},
{"getattr", OpGetattr, ErrOK, getattr, len(getattr)},
{"access", OpAccess, ErrOK, AppendAccessRes(nil, 3, 1), 8},
{"read", OpRead, ErrOK, AppendReadRes(nil, false, []byte("ab")), 4 + 4 + 2 + 2},
{"readdir", OpReaddir, ErrOK, readdir, len(readdir)},
}
for _, tc := range cases {
got, err := resBodyLen(tc.op, tc.status, tc.body)
if err != nil || got != tc.want {
t.Fatalf("%s: %d, %v; want %d", tc.name, got, err, tc.want)
}
}
if _, err := resBodyLen(OpWantDelegation, ErrOK, nil); !errors.Is(err, ErrUnknownOp) {
t.Fatalf("an unmeasurable result: %v", err)
}
if n, err := resBodyLen(OpGetfh, ErrOK, []byte{0, 0, 0, 9, 1}); err == nil || n != 0 {
t.Fatalf("a truncated getfh body measured as %d, %v", n, err)
}
}
func TestWriteWire(t *testing.T) {
args := AppendWriteArgs(nil, AllZero, 512, StableDataSync, []byte("data"))
stateid, off, stable, data, err := DecodeWriteArgs(args[4:])
if err != nil {
t.Fatalf("decode: %v", err)
}
if stateid != AllZero || off != 512 || stable != StableDataSync || string(data) != "data" {
t.Fatalf("write args: %+v %d %d %q", stateid, off, stable, data)
}
verf := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
res := AppendWriteRes(nil, 4, StableFileSync, verf)
count, committed, gotVerf, err := DecodeWriteRes(res)
if err != nil || count != 4 || committed != StableFileSync || gotVerf != verf {
t.Fatalf("write res: %d %d %x, %v", count, committed, gotVerf, err)
}
// The measured body length matches what was written.
n, err := resBodyLen(OpWrite, ErrOK, res)
if err != nil || n != len(res) {
t.Fatalf("measure: %d of %d, %v", n, len(res), err)
}
}
func TestCreateWire(t *testing.T) {
cases := []struct {
name string
kind uint32
linkdata string
major uint32
minor uint32
perm uint32
}{
{"dir", NF4Dir, "", 0, 0, 0o750},
{"symlink", NF4Lnk, "target.txt", 0, 0, 0o644},
{"fifo", NF4Fifo, "", 0, 0, 0o600},
{"chrdev", NF4Chr, "", 4, 64, 0o600},
{"nomode", NF4Sock, "", 0, 0, 0},
}
for _, tc := range cases {
arg := AppendCreateArgs(nil, tc.kind, "obj", tc.linkdata, tc.major, tc.minor, tc.perm)
kind, name, linkdata, major, minor, perm, err := DecodeCreateArgs(arg[4:])
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if kind != tc.kind || name != "obj" || linkdata != tc.linkdata ||
major != tc.major || minor != tc.minor || perm != tc.perm {
t.Fatalf("%s: kind %d name %q linkdata %q major %d minor %d perm %o",
tc.name, kind, name, linkdata, major, minor, perm)
}
}
}
func TestCreateResMeasure(t *testing.T) {
res := AppendCreateRes(nil)
n, err := resBodyLen(OpCreate, ErrOK, res)
if err != nil || n != len(res) {
t.Fatalf("measure: %d of %d, %v", n, len(res), err)
}
}
func TestFattrRoundTrip(t *testing.T) {
request := OfBits(AttrType, AttrChange, AttrSize, AttrFSID, AttrMode,
AttrNumlinks, AttrTimeModify, AttrMountedOnFileID, AttrFileHandle)
a := Attrs{
Type: NF4Reg,
FHExpireType: FH4Persistent,
Change: 0x1234,
Size: 9,
LinkSupport: true,
SymlinkSupport: true,
NamedAttr: false,
FSID: [2]uint64{0x700, 0},
UniqueHandles: true,
FileHandle: []byte("handle-bytes"),
FileID: 42,
Mode: 0o644,
Numlinks: 1,
SpaceUsed: 4096,
TimeModify: NfsTime{1700000000, 500},
MountedOnFileID: 42,
}
response, got, err := DecodeGetattrBody(AppendGetattrRes(nil, request, a))
if err != nil {
t.Fatalf("decode: %v", err)
}
for _, n := range []uint32{AttrType, AttrChange, AttrSize, AttrFSID, AttrMode,
AttrNumlinks, AttrTimeModify, AttrMountedOnFileID, AttrFileHandle} {
if !response.Has(n) {
t.Fatalf("attribute %d is missing from the response bitmap", n)
}
}
if got.Type != NF4Reg || got.Change != a.Change || got.Size != 9 ||
got.FSID != a.FSID || got.Mode != 0o644 || got.Numlinks != 1 ||
got.TimeModify != a.TimeModify || got.MountedOnFileID != 42 ||
!bytes.Equal(got.FileHandle, a.FileHandle) {
t.Fatalf("round trip mismatch: %+v", got)
}
}
func TestFattrSkipsUnsupported(t *testing.T) {
// Owner is encoded when the attributes carry it: a request that
// asks gets the decimal uid string in the blob and the response
// bitmap keeps it.
request := OfBits(AttrType, AttrOwner)
response, got, err := DecodeGetattrBody(AppendGetattrRes(nil, request, Attrs{Type: NF4Dir, Owner: "0"}))
if err != nil {
t.Fatalf("decode: %v", err)
}
if !response.Has(AttrType) || !response.Has(AttrOwner) {
t.Fatalf("response bitmap %v drops the owner", response.Words())
}
if got.Type != NF4Dir || got.Owner != "0" {
t.Fatalf("type %d owner %q", got.Type, got.Owner)
}
}
func TestFattrEmptyRequest(t *testing.T) {
response, _, err := DecodeGetattrBody(AppendGetattrRes(nil, Bitmap{}, Attrs{}))
if err != nil || len(response.Words()) != 0 {
t.Fatalf("an empty request: %v, %v", response, err)
}
}
func TestCompoundArgsRoundTrip(t *testing.T) {
var ops [][]byte
ops = append(ops, AppendPutRootfh(nil))
ops = append(ops, AppendPutfh(nil, []byte{1, 2, 3}))
ops = append(ops, AppendLookup(nil, "a.txt"))
payload := AppendCompoundArgs(nil, "probe", 2, ops)
h, d, err := DecodeCompoundArgs(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if h.Tag != "probe" || h.Minor != 2 || h.OpCount != 3 {
t.Fatalf("header %+v", h)
}
for i, want := range []uint32{OpPutRootfh, OpPutfh, OpLookup} {
got, err := d.Uint32()
if err != nil || got != want {
t.Fatalf("op %d: got %d, %v; want %d", i, got, err, want)
}
if want == OpPutfh {
if fh, err := d.VarOpaque(); err != nil || !bytes.Equal(fh, []byte{1, 2, 3}) {
t.Fatalf("putfh argument: %x, %v", fh, err)
}
}
if want == OpLookup {
if name, err := d.String(); err != nil || name != "a.txt" {
t.Fatalf("lookup component: %q, %v", name, err)
}
}
}
}
func TestCompoundResBodies(t *testing.T) {
var results [][]byte
results = append(results, AppendOpHeader(nil, OpPutRootfh, ErrOK))
fh := AppendGetfhRes(AppendOpHeader(nil, OpGetfh, ErrOK), []byte("the-handle"))
results = append(results, fh)
results = append(results, AppendOpHeader(nil, OpLookup, ErrNoEnt))
payload := AppendCompoundRes(nil, ErrNoEnt, "tag", results)
res, bodies, err := DecodeCompoundResBodies(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if res.Status != ErrNoEnt || res.Tag != "tag" || len(res.Ops) != 3 {
t.Fatalf("res: status %d tag %q ops %d", res.Status, res.Tag, len(res.Ops))
}
wantStatus := []uint32{ErrOK, ErrOK, ErrNoEnt}
for i, op := range res.Ops {
if op.Status != wantStatus[i] {
t.Fatalf("op %d status %d", i, op.Status)
}
}
if len(bodies[0]) != 0 || len(bodies[2]) != 0 {
t.Fatalf("a bodyless result carried bytes: %x %x", bodies[0], bodies[2])
}
gotFH, err := xdr.NewDecoder(bodies[1]).VarOpaque()
if err != nil || !bytes.Equal(gotFH, []byte("the-handle")) {
t.Fatalf("getfh body: %q, %v", gotFH, err)
}
}
func TestReadAndReaddirWire(t *testing.T) {
read := AppendRead(nil, AllZero, 512, 4096)
// The arguments follow the operation number.
d := xdr.NewDecoder(read[4:])
var st Stateid
raw, err := d.Raw(16)
if err != nil {
t.Fatalf("stateid: %v", err)
}
copy(st[:], raw)
off, err := d.Uint64()
count, err := d.Uint32()
if err != nil || off != 512 || count != 4096 {
t.Fatalf("read args: off %d count %d, %v", off, count, err)
}
res := AppendReadRes(nil, true, []byte("chunk"))
eof, data, err := DecodeReadBody(res)
if err != nil || !eof || string(data) != "chunk" {
t.Fatalf("read res: %v %q, %v", eof, data, err)
}
entries := []DirEntryRes{
{Cookie: 1, Name: "one", Attrs: Attrs{Type: NF4Reg}},
{Cookie: 2, Name: "two", Attrs: Attrs{Type: NF4Dir}},
}
req := OfBits(AttrType)
dir := AppendReadDirRes(nil, [8]byte{1, 2, 3, 4, 5, 6, 7, 8}, entries, req, true)
verf, got, eof, err := DecodeReadDirBody(dir)
if err != nil || !eof {
t.Fatalf("readdir: eof %v, %v", eof, err)
}
if verf != [8]byte{1, 2, 3, 4, 5, 6, 7, 8} {
t.Fatalf("verifier %x", verf)
}
if len(got) != 2 || got[0].Name != "one" || got[0].Cookie != 1 ||
got[1].Attrs.Type != NF4Dir {
t.Fatalf("entries %+v", got)
}
}
func TestWriteAndCreateTruncated(t *testing.T) {
full := AppendWriteArgs(nil, AllZero, 8, StableFileSync, []byte("abc"))
// The args without the data length fail: the data is length prefixed
// and the cut lands inside it.
if _, _, _, _, err := DecodeWriteArgs(full[4 : len(full)-1]); err == nil {
t.Fatal("a truncated write body decoded")
}
if _, _, _, _, err := DecodeWriteArgs(full[4 : 4+16+8+4]); err == nil {
t.Fatal("write args without data decoded")
}
// A CREATE whose attribute list is cut short fails.
cre := AppendCreateArgs(nil, NF4Fifo, "p", "", 0, 0, 0o644)
if _, _, _, _, _, _, err := DecodeCreateArgs(cre[4 : len(cre)-1]); err == nil {
t.Fatal("a truncated attribute list decoded")
}
}
func TestNfsTimeOf(t *testing.T) {
if got := NfsTimeOf(5, 6); got != (NfsTime{5, 6}) {
t.Fatalf("NfsTimeOf: %+v", got)
}
}
func TestRemoveAndRenameWire(t *testing.T) {
remove := AppendRemoveArgs(nil, "victim")
if op, err := xdr.NewDecoder(remove).Uint32(); err != nil || op != OpRemove {
t.Fatalf("remove op: %d, %v", op, err)
}
if name, err := xdr.NewDecoder(remove[4:]).String(); err != nil || name != "victim" {
t.Fatalf("remove name: %q, %v", name, err)
}
rename := AppendRenameArgs(nil, "old", "new")
d := xdr.NewDecoder(rename)
if op, err := d.Uint32(); err != nil || op != OpRename {
t.Fatalf("rename op: %d, %v", op, err)
}
oldName, err := d.String()
newName, err := d.String()
if err != nil || oldName != "old" || newName != "new" {
t.Fatalf("rename names: %q %q, %v", oldName, newName, err)
}
// The change info is twenty bytes, the rename result carries two of
// them, and the measurement walks the wire forms.
if n, err := resBodyLen(OpRemove, ErrOK, AppendRemoveRes(nil)); err != nil || n != 20 {
t.Fatalf("remove measure: %d, %v", n, err)
}
renameRes := AppendRenameRes(nil)
if n, err := resBodyLen(OpRename, ErrOK, renameRes); err != nil || n != len(renameRes) {
t.Fatalf("rename measure: %d of %d, %v", n, len(renameRes), err)
}
}
func TestDecodeSetattrBlob(t *testing.T) {
// mode, size, both times, owner and group in one walk.
request := OfBits(AttrMode, AttrSize, AttrTimeAccessSet, AttrTimeModifySet,
AttrOwner, AttrOwnerGroup)
// The values walk the blob in ascending attribute order.
var blob []byte
blob = xdr.AppendUint64(blob, 4096) // size
blob = xdr.AppendUint32(blob, 0o640) // mode
blob = xdr.AppendString(blob, "1000@EXAMPLE") // owner
blob = xdr.AppendString(blob, "100@EXAMPLE") // owner group
blob = xdr.AppendBool(blob, false) // access: explicit time
blob = xdr.AppendInt64(blob, 100)
blob = xdr.AppendUint32(blob, 5)
blob = xdr.AppendBool(blob, true) // modify: server time
u, err := DecodeSetattrBlob(blob, request)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !u.HasMode || u.Mode != 0o640 || !u.HasSize || u.Size != 4096 {
t.Fatalf("mode and size: %+v", u)
}
if u.Atime == nil || u.Atime.Server || u.Atime.Time != (NfsTime{100, 5}) {
t.Fatalf("atime: %+v", u.Atime)
}
if u.Mtime == nil || !u.Mtime.Server {
t.Fatalf("mtime: %+v", u.Mtime)
}
if u.UID == nil || *u.UID != 1000 || u.GID == nil || *u.GID != 100 {
t.Fatalf("owner: uid %v gid %v", u.UID, u.GID)
}
// A non numeric owner is refused, not silently dropped.
blob = xdr.AppendString(OfBits(AttrOwner).AppendTo(nil), "petr@EXAMPLE")
if _, err := DecodeSetattrBlob(blob, OfBits(AttrOwner)); err == nil {
t.Fatal("a non numeric owner decoded")
}
// An attribute the server cannot set stops the walk.
blob = append(xdr.AppendUint32(nil, 0), xdr.AppendUint32(nil, 0)...)
if _, err := DecodeSetattrBlob(blob, OfBits(AttrRawDev)); !errors.Is(err, ErrAttrNotSettable) {
t.Fatalf("rawdev: %v", err)
}
}
func TestSetattrAndLinkWire(t *testing.T) {
args := AppendSetattrArgs(nil, AllZero, OfBits(AttrMode), Attrs{Mode: 0o600})
// stateid first, then the fattr4: bitmap then length prefixed blob.
d := xdr.NewDecoder(args[4:])
if _, err := d.Raw(16); err != nil {
t.Fatalf("stateid: %v", err)
}
if _, err := ReadBitmap(d); err != nil {
t.Fatalf("bitmap: %v", err)
}
if _, err := d.VarOpaque(); err != nil {
t.Fatalf("blob: %v", err)
}
res := AppendSetattrRes(nil, OfBits(AttrMode, AttrSize))
attrsset, err := ReadBitmap(xdr.NewDecoder(res))
if err != nil || !attrsset.Has(AttrMode) || !attrsset.Has(AttrSize) {
t.Fatalf("attrsset: %v, %v", attrsset.Words(), err)
}
if n, err := resBodyLen(OpSetattr, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("setattr measure: %d of %d, %v", n, len(res), err)
}
link := AppendLinkArgs(nil, "newname")
if op, err := xdr.NewDecoder(link).Uint32(); err != nil || op != OpLink {
t.Fatalf("link op: %d, %v", op, err)
}
if name, err := xdr.NewDecoder(link[4:]).String(); err != nil || name != "newname" {
t.Fatalf("link name: %q, %v", name, err)
}
linkRes := AppendLinkRes(nil)
if n, err := resBodyLen(OpLink, ErrOK, linkRes); err != nil || n != len(linkRes) {
t.Fatalf("link measure: %d of %d, %v", n, len(linkRes), err)
}
}
func TestSetattrUpdatesWire(t *testing.T) {
uid, gid := uint32(1000), uint32(100)
u := SetAttrUpdates{
HasMode: true, Mode: 0o640,
HasSize: true, Size: 8192,
UID: &uid,
GID: &gid,
Atime: &NfsTimeSet{Server: true},
Mtime: &NfsTimeSet{Time: NfsTime{1_000_000_000, 7}},
}
arg := AppendSetattrArgsUpdates(nil, AllZero, u)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpSetattr {
t.Fatalf("op: %d, %v", op, err)
}
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, AllZero[:]) {
t.Fatalf("stateid: %x, %v", raw, err)
}
request, err := ReadBitmap(d)
blob, err := d.VarOpaque()
if err != nil {
t.Fatalf("fattr: %v", err)
}
for _, want := range []uint32{AttrMode, AttrSize, AttrOwner, AttrOwnerGroup,
AttrTimeAccessSet, AttrTimeModifySet} {
if !request.Has(want) {
t.Fatalf("the bitmap misses attribute %d", want)
}
}
got, err := DecodeSetattrBlob(blob, request)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !got.HasMode || got.Mode != u.Mode || !got.HasSize || got.Size != u.Size {
t.Fatalf("mode and size: %+v", got)
}
if got.UID == nil || *got.UID != uid || got.GID == nil || *got.GID != gid {
t.Fatalf("owner: %v %v", got.UID, got.GID)
}
if got.Atime == nil || !got.Atime.Server {
t.Fatalf("atime: %+v", got.Atime)
}
if got.Mtime == nil || got.Mtime.Server || got.Mtime.Time != u.Mtime.Time {
t.Fatalf("mtime: %+v", got.Mtime)
}
}
func TestReadlinkAndCommitWire(t *testing.T) {
link := AppendReadlinkArgs(nil)
if op, err := xdr.NewDecoder(link).Uint32(); err != nil || op != OpReadlink {
t.Fatalf("readlink op: %d, %v", op, err)
}
res := AppendReadlinkRes(nil, "target.txt")
got, err := xdr.NewDecoder(res).String()
if err != nil || got != "target.txt" {
t.Fatalf("readlink res: %q, %v", got, err)
}
if n, err := resBodyLen(OpReadlink, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("readlink measure: %d of %d, %v", n, len(res), err)
}
commit := AppendCommitArgs(nil, 512, 1024, [8]byte{7, 7, 7, 7, 7, 7, 7, 7})
d := xdr.NewDecoder(commit)
if op, err := d.Uint32(); err != nil || op != OpCommit {
t.Fatalf("commit op: %d, %v", op, err)
}
d = xdr.NewDecoder(commit[4:])
off, err := d.Uint64()
count, err := d.Uint32()
verf, err := d.Raw(8)
if err != nil || off != 512 || count != 1024 || verf[0] != 7 {
t.Fatalf("commit args: %d %d %x, %v", off, count, verf, err)
}
commitRes := AppendCommitRes(nil, [8]byte{1, 2, 3, 4, 5, 6, 7, 8})
if n, err := resBodyLen(OpCommit, ErrOK, commitRes); err != nil || n != 8 {
t.Fatalf("commit measure: %d, %v", n, err)
}
gotVerf, err := xdr.NewDecoder(commitRes).Raw(8)
if err != nil || gotVerf[7] != 8 {
t.Fatalf("commit res: %x, %v", gotVerf, err)
}
}
func TestSecinfoWire(t *testing.T) {
args := AppendSecinfoArgs(nil, "a.txt")
if op, err := xdr.NewDecoder(args).Uint32(); err != nil || op != OpSecinfo {
t.Fatalf("secinfo op: %d, %v", op, err)
}
if name, err := xdr.NewDecoder(args[4:]).String(); err != nil || name != "a.txt" {
t.Fatalf("secinfo name: %q, %v", name, err)
}
// The no name argument is the style enum alone: four bytes and
// nothing after them, RFC 7863.
noname := AppendSecinfoNoNameArgs(nil, StyleCurrentFH)
if len(noname) != 8 {
t.Fatalf("no name args of %d bytes, want 8", len(noname))
}
d := xdr.NewDecoder(noname)
if op, err := d.Uint32(); err != nil || op != OpSecinfoNoName {
t.Fatalf("no name op: %d, %v", op, err)
}
style, err := DecodeSecinfoNoNameArgs(d)
if err != nil || style != StyleCurrentFH || d.Remaining() != 0 {
t.Fatalf("no name style %d, %v", style, err)
}
if _, err := DecodeSecinfoNoNameArgs(xdr.NewDecoder(xdr.AppendUint32(nil, 5))); err == nil {
t.Fatal("an unknown style accepted")
}
// The result: one word per flavour, the rpcsec_gss_info body only on
// the RPCSEC_GSS branch, RFC 7863.
res := AppendSecinfoRes(nil, []SecinfoEntry{{Flavor: SecFlavorSys}})
if len(res) != 8 {
t.Fatalf("auth_sys entry of %d bytes, want 8", len(res))
}
entries, err := DecodeSecinfoRes(res)
if err != nil || len(entries) != 1 || entries[0].Flavor != SecFlavorSys ||
entries[0].GSS != nil {
t.Fatalf("secinfo res: %+v, %v", entries, err)
}
if n, err := resBodyLen(OpSecinfo, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("secinfo measure: %d of %d, %v", n, len(res), err)
}
gssRes := AppendSecinfoRes(nil, []SecinfoEntry{
{Flavor: SecFlavorSys},
{Flavor: SecFlavorGSS, GSS: &GSSInfo{OID: []byte{0x2a}, QOP: 0, Service: 1}},
})
entries, err = DecodeSecinfoRes(gssRes)
if err != nil || len(entries) != 2 || entries[1].GSS == nil ||
len(entries[1].GSS.OID) != 1 || entries[1].GSS.Service != 1 {
t.Fatalf("gss entry: %+v, %v", entries, err)
}
if n, err := resBodyLen(OpSecinfoNoName, ErrOK, gssRes); err != nil || n != len(gssRes) {
t.Fatalf("no name measure: %d of %d, %v", n, len(gssRes), err)
}
}
func TestDecodeLocktDeniedCompound(t *testing.T) {
var ops [][]byte
ops = append(ops, append(AppendOpHeader(nil, OpSequence, ErrOK), make([]byte, 36)...))
ops = append(ops, AppendOpHeader(nil, OpPutRootfh, ErrOK))
deniedBody := AppendLocktResDenied(AppendOpHeader(nil, OpLockt, ErrDenied),
0, 100, LockTypeWrite, 0x1111, []byte("locker-a"))
ops = append(ops, deniedBody)
payload := AppendCompoundRes(nil, ErrDenied, "t", ops)
res, bodies, err := DecodeCompoundResBodies(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if res.Status != ErrDenied || len(res.Ops) != 3 {
t.Fatalf("res: status %d ops %d", res.Status, len(res.Ops))
}
for i, op := range res.Ops {
t.Logf("op %d: op=%d status=%d bodylen=%d", i, op.Op, op.Status, len(bodies[i]))
}
if len(bodies[2]) == 0 {
t.Fatal("the denied body is empty")
}
got, err := DecodeLocktResBody(res.Status, bodies[2])
if err != nil {
t.Fatalf("denied decode: %v", err)
}
if got.ClientID != 0x1111 || got.LockType != LockTypeWrite || got.Length != 100 {
t.Fatalf("denied: %+v", got)
}
}
func TestLockWire(t *testing.T) {
openSt := AllZero
arg := AppendLockArgsNew(nil, openSt, 0x1111, []byte("owner"), LockTypeWrite, false, 8, 16)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpLock {
t.Fatalf("op: %d, %v", op, err)
}
if lt, err := d.Uint32(); err != nil || lt != LockTypeWrite {
t.Fatalf("locktype: %v", err)
}
if reclaim, err := d.Bool(); err != nil || reclaim {
t.Fatal("reclaim set")
}
if off, err := d.Uint64(); err != nil || off != 8 {
t.Fatalf("offset: %v", err)
}
if length, err := d.Uint64(); err != nil || length != 16 {
t.Fatalf("length: %v", err)
}
if newOwner, err := d.Bool(); err != nil || !newOwner {
t.Fatal("new owner flag missing")
}
if _, err := d.Uint32(); err != nil { // open seqid
t.Fatal(err)
}
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, openSt[:]) {
t.Fatal("open stateid mismatch")
}
if _, err := d.Uint32(); err != nil { // lock seqid
t.Fatal(err)
}
if clientid, err := d.Uint64(); err != nil || clientid != 0x1111 {
t.Fatalf("clientid: %v", err)
}
if owner, err := d.VarOpaque(); err != nil || string(owner) != "owner" {
t.Fatalf("owner: %q, %v", owner, err)
}
existing := AppendLockArgsExisting(nil, AllZero, LockTypeRead, 0, 32)
d = xdr.NewDecoder(existing[4:])
if _, err := d.Uint32(); err != nil { // lock type
t.Fatal(err)
}
if reclaim, err := d.Bool(); err != nil || reclaim {
t.Fatal("existing lock marked as reclaim")
}
if off, err := d.Uint64(); err != nil || off != 0 {
t.Fatalf("offset: %v", err)
}
if length, err := d.Uint64(); err != nil || length != 32 {
t.Fatalf("length: %v", err)
}
if newOwner, err := d.Bool(); err != nil || newOwner {
t.Fatal("existing lock carried the new owner form")
}
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
if _, err := d.Uint32(); err != nil { // lock seqid
t.Fatal(err)
}
var lockSt Stateid
lockSt[4], lockSt[5], lockSt[6], lockSt[7] = 'L', 'O', 'C', 'K'
lockRes := AppendLockRes(nil, lockSt)
if n, err := resBodyLen(OpLock, ErrOK, lockRes); err != nil || n != 16 {
t.Fatalf("lock measure: %d, %v", n, err)
}
lockt := AppendLocktArgs(nil, LockTypeRead, 0, 64, 0x2222, []byte("prober"))
d = xdr.NewDecoder(lockt[4:])
if lt, err := d.Uint32(); err != nil || lt != LockTypeRead {
t.Fatalf("lockt type: %v", err)
}
if _, err := d.Uint64(); err != nil {
t.Fatal(err)
}
if _, err := d.Uint64(); err != nil {
t.Fatal(err)
}
if cid, err := d.Uint64(); err != nil || cid != 0x2222 {
t.Fatalf("lockt clientid: %v", err)
}
if owner, err := d.VarOpaque(); err != nil || string(owner) != "prober" {
t.Fatalf("lockt owner: %q, %v", owner, err)
}
denied := AppendLocktResDenied(nil, 0, 100, LockTypeWrite, 0x3333, []byte("holder"))
got, err := DecodeLocktResBody(ErrDenied, denied)
if err != nil || got.Offset != 0 || got.Length != 100 ||
got.LockType != LockTypeWrite || got.ClientID != 0x3333 ||
string(got.Owner) != "holder" {
t.Fatalf("denied: %+v, %v", got, err)
}
if n, err := resBodyLenLockt(ErrDenied, denied); err != nil || n != len(denied) {
t.Fatalf("lockt measure: %d of %d, %v", n, len(denied), err)
}
if _, err := resBodyLenLockt(ErrDenied, []byte{0, 0, 0, 0, 0, 0, 0, 0}); err == nil {
t.Fatal("a truncated denied body measured without error")
}
}
+1328
View File
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The codecs of the remaining NFSv4.1 operations: the parent lookups, the
// attribute comparisons, the lock owner release and the session binding
// family.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Connection direction and binding values of the CREATE_SESSION and
// BIND_CONN_TO_SESSION channel directory, RFC 8881 section 2.10.1.
const (
Cdfc4Fore = 1
Cdfc4Back = 2
Cdfc4ForeOrBoth = 3
Cdfc4BackOrBoth = 7
Cdfs4Fore = 1
Cdfs4Back = 2
Cdfs4Both = 3
Cdfs4Arrow = 4
)
// AppendLookupp appends the LOOKUPP argop, which carries no arguments.
func AppendLookupp(b []byte) []byte {
return xdr.AppendUint32(b, OpLookupp)
}
// AppendPutPubfh appends the PUTPUBFH argop, which carries no arguments.
func AppendPutPubfh(b []byte) []byte {
return xdr.AppendUint32(b, OpPutPubfh)
}
// AppendVerifyArgs appends the VERIFY argop: the attributes the file must
// carry for the compound to proceed. NVERIFY shares the shape and demands
// the opposite.
func AppendVerifyArgs(b []byte, request Bitmap, a Attrs) []byte {
b = xdr.AppendUint32(b, OpVerify)
return AppendFattr(b, request, a)
}
// AppendNverifyArgs appends the NVERIFY argop: the attributes the file
// must not carry.
func AppendNverifyArgs(b []byte, request Bitmap, a Attrs) []byte {
b = xdr.AppendUint32(b, OpNverify)
return AppendFattr(b, request, a)
}
// AppendReleaseLockOwnerArgs appends the RELEASE_LOCKOWNER argop: the
// client whose lock owner dies and the owner itself.
func AppendReleaseLockOwnerArgs(b []byte, clientid uint64, owner []byte) []byte {
b = xdr.AppendUint32(b, OpReleaseLockOwner)
b = xdr.AppendUint64(b, clientid)
return xdr.AppendVarOpaque(b, owner)
}
// AppendDelegReturnArgs appends the DELEGRETURN argop: the stateid of the
// delegation the client gives back.
func AppendDelegReturnArgs(b []byte, st Stateid) []byte {
b = xdr.AppendUint32(b, OpDelegReturn)
return st.Append(b)
}
// AppendBackchannelCtlArgs appends the BACKCHANNEL_CTL argop: the program
// number of the back channel the client runs.
func AppendBackchannelCtlArgs(b []byte, program uint32) []byte {
b = xdr.AppendUint32(b, OpBackchannelCtl)
return xdr.AppendUint32(b, program)
}
// AppendBindConnToSessionArgs appends the BIND_CONN_TO_SESSION argop of
// RFC 7863: the session, the direction the connection should serve and
// the RDMA mode flag.
func AppendBindConnToSessionArgs(b []byte, id SessionID, dir uint32, rdma bool) []byte {
b = xdr.AppendUint32(b, OpBindConnToSession)
b = append(b, id[:]...)
b = xdr.AppendUint32(b, dir)
return xdr.AppendBool(b, rdma)
}
// AppendBindConnToSessionRes appends the result: the session and the
// direction the server granted.
func AppendBindConnToSessionRes(b []byte, id SessionID, dir uint32) []byte {
b = append(b, id[:]...)
return xdr.AppendUint32(b, dir)
}
// AppendFreeStateidArgs appends the FREE_STATEID argop: the lock stateid
// the client retires.
func AppendFreeStateidArgs(b []byte, st Stateid) []byte {
b = xdr.AppendUint32(b, OpFreeStateid)
return st.Append(b)
}
// AppendTestStateidArgs appends the TEST_STATEID argop: the stateids the
// client asks about.
func AppendTestStateidArgs(b []byte, sts []Stateid) []byte {
b = xdr.AppendUint32(b, OpTestStateid)
b = xdr.AppendUint32(b, uint32(len(sts)))
for _, st := range sts {
b = st.Append(b)
}
return b
}
// AppendTestStateidRes appends the result: one status per stateid, in the
// order the arguments named them.
func AppendTestStateidRes(b []byte, statuses []uint32) []byte {
b = xdr.AppendUint32(b, uint32(len(statuses)))
for _, s := range statuses {
b = xdr.AppendUint32(b, s)
}
return b
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The wire codecs of the NFSv4.2 operations, RFC 7862, with the shapes
// taken from the XDR of RFC 7863.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Content types of the data_content4 enum, RFC 7862 section 8.2.
const (
ContentData = 0
ContentHole = 1
)
// Error codes the copy family answers with, RFC 7862 section 15.1.
const (
ErrOffloadDenied = 10091
ErrOffloadNoReqs = 10094
NfsSyncNothing = 0
NfsSyncDataSync = 1
NfsSyncFileSync = 2
OffloadNotReq = 0
OffloadSuccess = 1
OffloadDenied = 2
OffloadFatal = 3
OffloadNoFallback = 4
)
// AppendSeekArgs encodes the SEEK argop: the stateid, the offset and the
// content kind the client searches for.
func AppendSeekArgs(b []byte, st Stateid, offset uint64, what uint32) []byte {
b = xdr.AppendUint32(b, OpSeek)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint32(b, what)
}
// AppendSeekRes encodes a successful SEEK result: the end of file flag
// and the offset of the next content of the requested kind.
func AppendSeekRes(b []byte, eof bool, offset uint64) []byte {
b = xdr.AppendBool(b, eof)
return xdr.AppendUint64(b, offset)
}
// AppendAllocateArgs encodes the ALLOCATE argop: the reserved range.
func AppendAllocateArgs(b []byte, st Stateid, offset, length uint64) []byte {
b = xdr.AppendUint32(b, OpAllocate)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint64(b, length)
}
// AppendDeallocateArgs encodes the DEALLOCATE argop: the released range.
func AppendDeallocateArgs(b []byte, st Stateid, offset, length uint64) []byte {
b = xdr.AppendUint32(b, OpDeallocate)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint64(b, length)
}
// AppendIoAdviseArgs encodes the IO_ADVISE argop: the range and the hint
// bitmap.
func AppendIoAdviseArgs(b []byte, st Stateid, offset, length uint64, hints Bitmap) []byte {
b = xdr.AppendUint32(b, OpIoAdvise)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
return hints.AppendTo(b)
}
// AppendIoAdviseRes encodes a successful IO_ADVISE result: the hints the
// server accepted.
func AppendIoAdviseRes(b []byte, hints Bitmap) []byte {
return hints.AppendTo(b)
}
// A CopySourceServer is one netloc4 of the COPY argument list: the
// servers the source lives on.
type CopySourceServer struct {
Type uint32
Name string
Addr NetAddr
}
// AppendCopyArgs encodes the COPY argop: the source and destination
// stateids, the ranges and the source server list.
func AppendCopyArgs(b []byte, src, dst Stateid, srcOff, dstOff, count uint64, consecutive, synchronous bool, sources []CopySourceServer) []byte {
b = xdr.AppendUint32(b, OpCopy)
b = src.Append(b)
b = dst.Append(b)
b = xdr.AppendUint64(b, srcOff)
b = xdr.AppendUint64(b, dstOff)
b = xdr.AppendUint64(b, count)
b = xdr.AppendBool(b, consecutive)
b = xdr.AppendBool(b, synchronous)
b = xdr.AppendUint32(b, uint32(len(sources)))
for _, s := range sources {
b = xdr.AppendUint32(b, s.Type)
switch s.Type {
case 1, 2:
b = xdr.AppendString(b, s.Name)
case 3:
b = xdr.AppendString(b, s.Addr.Netid)
b = xdr.AppendString(b, s.Addr.Uaddr)
}
}
return b
}
// AppendWriteResponse encodes the write_response4 the copy family
// answers with.
func AppendWriteResponse(b []byte, callbackSt Stateid, callbackSet bool, count uint64, committed uint32, verifier [8]byte) []byte {
if callbackSet {
b = xdr.AppendUint32(b, 1)
b = callbackSt.Append(b)
} else {
b = xdr.AppendUint32(b, 0)
}
b = xdr.AppendUint64(b, count)
b = xdr.AppendUint32(b, committed)
return append(b, verifier[:]...)
}
// AppendCopyRes encodes a successful COPY result: the write response and
// the requirements the server met.
func AppendCopyRes(b []byte, callbackSt Stateid, callbackSet bool, count uint64, committed uint32, verifier [8]byte, consecutive, synchronous bool) []byte {
b = AppendWriteResponse(b, callbackSt, callbackSet, count, committed, verifier)
b = xdr.AppendBool(b, consecutive)
return xdr.AppendBool(b, synchronous)
}
// AppendCopyNotifyArgs encodes the COPY_NOTIFY argop: the source stateid
// and the destination the client copies to.
func AppendCopyNotifyArgs(b []byte, st Stateid, dstType uint32, dstName string, dstAddr NetAddr) []byte {
b = xdr.AppendUint32(b, OpCopyNotify)
b = st.Append(b)
b = xdr.AppendUint32(b, dstType)
switch dstType {
case 1, 2:
b = xdr.AppendString(b, dstName)
case 3:
b = xdr.AppendString(b, dstAddr.Netid)
b = xdr.AppendString(b, dstAddr.Uaddr)
}
return b
}
// AppendCopyNotifyRes encodes a successful COPY_NOTIFY result: the lease
// the authorization lives, the stateid naming it and the source servers.
func AppendCopyNotifyRes(b []byte, leaseSeconds int64, st Stateid, sources []CopySourceServer) []byte {
b = xdr.AppendInt64(b, leaseSeconds)
b = xdr.AppendUint32(b, 0)
b = st.Append(b)
b = xdr.AppendUint32(b, uint32(len(sources)))
for _, s := range sources {
b = xdr.AppendUint32(b, s.Type)
switch s.Type {
case 1, 2:
b = xdr.AppendString(b, s.Name)
case 3:
b = xdr.AppendString(b, s.Addr.Netid)
b = xdr.AppendString(b, s.Addr.Uaddr)
}
}
return b
}
// AppendOffloadCancelArgs encodes the OFFLOAD_CANCEL argop: the stateid
// of the copy to abandon.
func AppendOffloadCancelArgs(b []byte, st Stateid) []byte {
b = xdr.AppendUint32(b, OpOffloadCancel)
return st.Append(b)
}
// AppendOffloadStatusArgs encodes the OFFLOAD_STATUS argop: the stateid
// of the copy in flight.
func AppendOffloadStatusArgs(b []byte, st Stateid) []byte {
b = xdr.AppendUint32(b, OpOffloadStatus)
return st.Append(b)
}
// AppendOffloadStatusRes encodes a successful OFFLOAD_STATUS result: the
// bytes copied so far and the completion code.
func AppendOffloadStatusRes(b []byte, count uint64, complete uint32) []byte {
b = xdr.AppendUint64(b, count)
return xdr.AppendUint32(b, complete)
}
// AppendCloneArgs encodes the CLONE argop: the ranges of the clone. The
// saved file handle is the source, the current one the destination.
func AppendCloneArgs(b []byte, src, dst Stateid, srcOff, dstOff, count uint64) []byte {
b = xdr.AppendUint32(b, OpClone)
b = src.Append(b)
b = dst.Append(b)
b = xdr.AppendUint64(b, srcOff)
b = xdr.AppendUint64(b, dstOff)
return xdr.AppendUint64(b, count)
}
// AppendLayoutErrorArgs encodes the LAYOUTERROR argop: the failing range
// and one error per device.
func AppendLayoutErrorArgs(b []byte, offset, length uint64, st Stateid, device [16]byte, status, opnum uint32) []byte {
b = xdr.AppendUint32(b, OpLayoutError)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = st.Append(b)
b = xdr.AppendUint32(b, 1)
b = append(b, device[:]...)
b = xdr.AppendUint32(b, status)
return xdr.AppendUint32(b, opnum)
}
// AppendLayoutStatsArgs encodes the LAYOUTSTATS argop: the reported range
// and the IO counters of the data server.
func AppendLayoutStatsArgs(b []byte, offset, length uint64, st Stateid, readCount, readBytes, writeCount, writeBytes uint64, device [16]byte, updateType uint32, updateBody []byte) []byte {
b = xdr.AppendUint32(b, OpLayoutStats)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = st.Append(b)
b = xdr.AppendUint64(b, readCount)
b = xdr.AppendUint64(b, readBytes)
b = xdr.AppendUint64(b, writeCount)
b = xdr.AppendUint64(b, writeBytes)
b = append(b, device[:]...)
b = xdr.AppendUint32(b, updateType)
return xdr.AppendVarOpaque(b, updateBody)
}
// AppendReadPlusArgs encodes the READ_PLUS argop: the range to read.
func AppendReadPlusArgs(b []byte, st Stateid, offset uint64, count uint32) []byte {
b = xdr.AppendUint32(b, OpReadPlus)
b = st.Append(b)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint32(b, count)
}
// AppendReadPlusDataRes encodes a successful READ_PLUS result that
// carries one data segment.
func AppendReadPlusDataRes(b []byte, eof bool, offset uint64, data []byte) []byte {
b = xdr.AppendBool(b, eof)
b = xdr.AppendUint32(b, 1)
b = xdr.AppendUint32(b, ContentData)
b = xdr.AppendUint64(b, offset)
return xdr.AppendVarOpaque(b, data)
}
// AppendReadPlusHoleRes encodes a successful READ_PLUS result that
// reports a hole.
func AppendReadPlusHoleRes(b []byte, eof bool, offset, length uint64) []byte {
b = xdr.AppendBool(b, eof)
b = xdr.AppendUint32(b, 1)
b = xdr.AppendUint32(b, ContentHole)
b = xdr.AppendUint64(b, offset)
return xdr.AppendUint64(b, length)
}
// AppendWriteSameArgs encodes the WRITE_SAME argop: the stateid, the
// stability and the application data block to write. The block number
// is a count4 on the wire, RFC 7862 section 15.8.
func AppendWriteSameArgs(b []byte, st Stateid, stable uint32, offset, blockSize, blockCount, relOffBlocknum uint64, blockNum uint32, relOffPattern uint64, pattern []byte) []byte {
b = xdr.AppendUint32(b, OpWriteSame)
b = st.Append(b)
b = xdr.AppendUint32(b, stable)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, blockSize)
b = xdr.AppendUint64(b, blockCount)
b = xdr.AppendUint64(b, relOffBlocknum)
b = xdr.AppendUint32(b, blockNum)
b = xdr.AppendUint64(b, relOffPattern)
return xdr.AppendVarOpaque(b, pattern)
}
// AppendWriteSameRes encodes a successful WRITE_SAME result: the write
// response alone.
func AppendWriteSameRes(b []byte, count uint64, committed uint32, verifier [8]byte) []byte {
b = xdr.AppendUint32(b, 0)
b = xdr.AppendUint64(b, count)
b = xdr.AppendUint32(b, committed)
return append(b, verifier[:]...)
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestOps42Shapes(t *testing.T) {
t.Parallel()
st := Stateid{2, 'S'}
// SEEK args and res.
seek := AppendSeekArgs(nil, st, 8, ContentHole)
d := xdr.NewDecoder(seek[4:])
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if off, err := d.Uint64(); err != nil || off != 8 {
t.Fatalf("offset %d: %v", off, err)
}
if what, err := d.Uint32(); err != nil || what != ContentHole {
t.Fatalf("what %d: %v", what, err)
}
res := AppendSeekRes(nil, true, 4096)
if n, err := resBodyLen(OpSeek, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("seek res measured %d of %d: %v", n, len(res), err)
}
// COPY res measures through the write response and requirements.
verifier := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
copyRes := AppendCopyRes(nil, st, false, 128, NfsSyncFileSync, verifier, true, true)
if n, err := resBodyLen(OpCopy, ErrOK, copyRes); err != nil || n != len(copyRes) {
t.Fatalf("copy res measured %d of %d: %v", n, len(copyRes), err)
}
// READ_PLUS res with one data segment.
rp := AppendReadPlusDataRes(nil, false, 0, []byte("data"))
if n, err := resBodyLen(OpReadPlus, ErrOK, rp); err != nil || n != len(rp) {
t.Fatalf("read plus res measured %d of %d: %v", n, len(rp), err)
}
rph := AppendReadPlusHoleRes(nil, true, 0, 4096)
if n, err := resBodyLen(OpReadPlus, ErrOK, rph); err != nil || n != len(rph) {
t.Fatalf("read plus hole res measured %d of %d: %v", n, len(rph), err)
}
// WRITE_SAME args decode through the adb fields. The block number
// is a count4 on the wire, RFC 7862.
ws := AppendWriteSameArgs(nil, st, StableFileSync, 8, 512, 2, 0, 0, 0, []byte("ab"))
d = xdr.NewDecoder(ws[4:])
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if stable, err := d.Uint32(); err != nil || stable != StableFileSync {
t.Fatalf("stable %d: %v", stable, err)
}
if off, err := d.Uint64(); err != nil || off != 8 {
t.Fatalf("offset %d: %v", off, err)
}
for _, want := range []uint64{512, 2, 0} {
if v, err := d.Uint64(); err != nil || v != want {
t.Fatalf("adb field %d: %v", v, err)
}
}
if bn, err := d.Uint32(); err != nil || bn != 0 {
t.Fatalf("adb block num %d: %v", bn, err)
}
if rp, err := d.Uint64(); err != nil || rp != 0 {
t.Fatalf("adb rel off pattern %d: %v", rp, err)
}
if p, err := d.VarOpaque(); err != nil || string(p) != "ab" {
t.Fatalf("pattern %q: %v", p, err)
}
wsr := AppendWriteSameRes(nil, 1024, NfsSyncFileSync, verifier)
if n, err := resBodyLen(OpWriteSame, ErrOK, wsr); err != nil || n != len(wsr) {
t.Fatalf("write same res measured %d of %d: %v", n, len(wsr), err)
}
// The small ops: allocate, io advise, offload status.
al := AppendAllocateArgs(nil, st, 0, 100)
d = xdr.NewDecoder(al[4:])
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
if _, err := d.Uint64(); err != nil {
t.Fatal(err)
}
if l, err := d.Uint64(); err != nil || l != 100 {
t.Fatalf("length %d: %v", l, err)
}
hints := AppendIoAdviseRes(nil, Bitmap{}.With(2))
if n, err := resBodyLen(OpIoAdvise, ErrOK, hints); err != nil || n != len(hints) {
t.Fatalf("io advise res measured %d of %d: %v", n, len(hints), err)
}
os := AppendOffloadStatusRes(nil, 64, OffloadSuccess)
if n, err := resBodyLen(OpOffloadStatus, ErrOK, os); err != nil || n != len(os) {
t.Fatalf("offload status res measured %d of %d: %v", n, len(os), err)
}
clone := AppendCloneArgs(nil, st, st, 1, 2, 3)
d = xdr.NewDecoder(clone[4:])
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
for _, want := range []uint64{1, 2, 3} {
if v, err := d.Uint64(); err != nil || v != want {
t.Fatalf("clone field %d: %v", v, err)
}
}
}
func TestOps42RemainingShapes(t *testing.T) {
t.Parallel()
st := Stateid{4, 'C'}
// The remaining arg encoders: op code in front, fields in order.
bodies := map[string][]byte{
"deallocate": AppendDeallocateArgs(nil, st, 1, 2),
"ioadvise": AppendIoAdviseArgs(nil, st, 1, 2, Bitmap{}.With(1)),
"copy": AppendCopyArgs(nil, st, st, 1, 2, 3, true, false, nil),
"copynotify": AppendCopyNotifyArgs(nil, st, 3, "", NetAddr{Netid: "tcp", Uaddr: "1.2.3.4.8.1"}),
"cancel": AppendOffloadCancelArgs(nil, st),
"status": AppendOffloadStatusArgs(nil, st),
"layerr": AppendLayoutErrorArgs(nil, 1, 2, st, layoutTestDevice, ErrBadStateid, OpRead),
"laystats": AppendLayoutStatsArgs(nil, 1, 2, st, 1, 2, 3, 4, layoutTestDevice, 0, nil),
"readplus": AppendReadPlusArgs(nil, st, 1, 512),
}
for _, body := range bodies {
d := xdr.NewDecoder(body[4:])
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
}
cn := AppendCopyNotifyRes(nil, 90, st, []CopySourceServer{{Type: 2, Name: "dst"}})
if n, err := resBodyLen(OpCopyNotify, ErrOK, cn); err != nil || n != len(cn) {
t.Fatalf("copy notify res measured %d of %d: %v", n, len(cn), err)
}
// A COPY result whose write response carries the callback stateid
// arm measures through the same shape.
wr := AppendCopyRes(nil, st, true, 10, NfsSyncDataSync, [8]byte{}, false, true)
if n, err := resBodyLen(OpCopy, ErrOK, wr); err != nil || n != len(wr) {
t.Fatalf("copy res with callback measured %d of %d: %v", n, len(wr), err)
}
}
var layoutTestDevice = [16]byte{1, 2, 3}
+319
View File
@@ -0,0 +1,319 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The pNFS layout layer: the wire codecs of LAYOUTGET, LAYOUTCOMMIT,
// LAYOUTRETURN and GETDEVICEINFO over the flexfiles layout of RFC 8435.
// The shapes follow the XDR of RFC 8881 sections 18.43 to 18.45 and RFC
// 8435 section 6.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// Layout types of the layouttype4 enum, the registry values of RFC 7863
// and RFC 8435: files, objects and block volume are the NFSv4.1 core,
// flexfiles is four, SCSI five.
const (
LayoutTypeFiles = 1
LayoutTypeObjects = 2
LayoutTypeBlock = 3
LayoutTypeFlexfiles = 4
LayoutTypeScsi = 5
)
// Layout IO modes of the layoutiomode4 enum, RFC 7863.
const (
IoModeRead = 1
IoModeRW = 2
IoModeAny = 3
)
// LAYOUTCOMMIT uses the newtime4 union for the modify time a client
// asserts.
type NewTime struct {
Set bool
Seconds int64
Nseconds uint32
}
// AppendLayoutGetArgs encodes the whole LAYOUTGET argop: the signal flag,
// the layout type, the IO mode, the range, the stateid the layout hangs
// from and the response size budget.
func AppendLayoutGetArgs(b []byte, signalAvail bool, layoutType, iomode uint32, offset, length, minlength uint64, st Stateid, maxcount uint32) []byte {
b = xdr.AppendUint32(b, OpLayoutGet)
b = xdr.AppendBool(b, signalAvail)
b = xdr.AppendUint32(b, layoutType)
b = xdr.AppendUint32(b, iomode)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendUint64(b, minlength)
b = st.Append(b)
return xdr.AppendUint32(b, maxcount)
}
// A Layout4 is one segment of a granted layout: the byte range, the IO
// mode and the type specific body.
type Layout4 struct {
Offset uint64
Length uint64
IoMode uint32
Type uint32
Body []byte
}
// AppendLayout4 encodes one layout4.
func AppendLayout4(b []byte, l Layout4) []byte {
b = xdr.AppendUint64(b, l.Offset)
b = xdr.AppendUint64(b, l.Length)
b = xdr.AppendUint32(b, l.IoMode)
b = xdr.AppendUint32(b, l.Type)
return xdr.AppendVarOpaque(b, l.Body)
}
// AppendLayoutGetRes encodes a successful LAYOUTGET result: the layout
// stateid, the return-on-close flag and the layouts.
func AppendLayoutGetRes(b []byte, layoutSt Stateid, returnOnClose bool, layouts []Layout4) []byte {
b = layoutSt.Append(b)
b = xdr.AppendBool(b, returnOnClose)
b = xdr.AppendUint32(b, uint32(len(layouts)))
for _, l := range layouts {
b = AppendLayout4(b, l)
}
return b
}
// AppendLayoutCommitArgs encodes the LAYOUTCOMMIT argop body: the range,
// the reclaim flag, the layout stateid, the last write offset, the modify
// time and the layout update record.
func AppendLayoutCommitArgs(b []byte, offset, length uint64, reclaim bool, layoutSt Stateid, lastWriteSet bool, lastWrite uint64, timeSet bool, seconds int64, nseconds uint32, updateType uint32, updateBody []byte) []byte {
b = xdr.AppendUint32(b, OpLayoutCommit)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = xdr.AppendBool(b, reclaim)
b = layoutSt.Append(b)
if lastWriteSet {
b = xdr.AppendBool(b, true)
b = xdr.AppendUint64(b, lastWrite)
} else {
b = xdr.AppendBool(b, false)
}
if timeSet {
b = xdr.AppendBool(b, true)
b = xdr.AppendInt64(b, seconds)
b = xdr.AppendUint32(b, nseconds)
} else {
b = xdr.AppendBool(b, false)
}
b = xdr.AppendUint32(b, updateType)
return xdr.AppendVarOpaque(b, updateBody)
}
// AppendLayoutCommitRes encodes a successful LAYOUTCOMMIT result: the
// newsize4 union with the size the layout commit leaves the file at.
func AppendLayoutCommitRes(b []byte, newSize uint64) []byte {
b = xdr.AppendBool(b, true)
return xdr.AppendUint64(b, newSize)
}
// AppendLayoutReturnArgs encodes the LAYOUTRETURN argop of the whole file
// system or the whole client: no layoutreturn_file4 body follows.
func AppendLayoutReturnArgs(b []byte, reclaim bool, layoutType, iomode, returnKind uint32) []byte {
b = xdr.AppendUint32(b, OpLayoutReturn)
b = xdr.AppendBool(b, reclaim)
b = xdr.AppendUint32(b, layoutType)
b = xdr.AppendUint32(b, iomode)
return xdr.AppendUint32(b, returnKind)
}
// AppendLayoutReturnFileArgs encodes the LAYOUTRETURN argop of one file:
// the offset, the length and the layout stateid ride after the header,
// with the empty ffsid body of NFSv4.2.
func AppendLayoutReturnFileArgs(b []byte, reclaim bool, layoutType, iomode uint32, offset, length uint64, st Stateid) []byte {
b = AppendLayoutReturnArgs(b, reclaim, layoutType, iomode, ReturnFile)
b = xdr.AppendUint64(b, offset)
b = xdr.AppendUint64(b, length)
b = st.Append(b)
return xdr.AppendUint32(b, 0) // ffsid_info_type: the empty default
}
// AppendLayoutReturnRes encodes a successful LAYOUTRETURN result: the
// layoutreturn_stateid4 union with the returned flag set and the stateid
// of the layout as returned.
func AppendLayoutReturnRes(b []byte, stateid Stateid) []byte {
b = xdr.AppendBool(b, true)
return stateid.Append(b)
}
// AppendGetDeviceInfoArgs encodes the GETDEVICEINFO argop: the device id,
// the layout type, the response size budget and the notification types.
func AppendGetDeviceInfoArgs(b []byte, deviceID [16]byte, layoutType, maxcount uint32, notifyTypes Bitmap) []byte {
b = xdr.AppendUint32(b, OpGetDeviceInfo)
b = append(b, deviceID[:]...)
b = xdr.AppendUint32(b, layoutType)
b = xdr.AppendUint32(b, maxcount)
return notifyTypes.AppendTo(b)
}
// AppendGetDeviceInfoRes encodes a successful GETDEVICEINFO result: the
// device address of the flexfiles layout and the notification bitmap.
func AppendGetDeviceInfoRes(b []byte, addrBody []byte) []byte {
b = xdr.AppendUint32(b, LayoutTypeFlexfiles)
b = xdr.AppendVarOpaque(b, addrBody)
return Bitmap{}.AppendTo(b)
}
// A LayoutCommitArgs is the structured form of the LAYOUTCOMMIT argop.
type LayoutCommitArgs struct {
Offset uint64
Length uint64
Reclaim bool
LayoutSt Stateid
LastWriteSet bool
LastWrite uint64
TimeSet bool
Seconds int64
Nseconds uint32
UpdateType uint32
UpdateBody []byte
}
// Encode builds the whole LAYOUTCOMMIT argop.
func (a LayoutCommitArgs) Encode() []byte {
return AppendLayoutCommitArgs(nil, a.Offset, a.Length, a.Reclaim, a.LayoutSt,
a.LastWriteSet, a.LastWrite, a.TimeSet, a.Seconds, a.Nseconds, a.UpdateType, a.UpdateBody)
}
// A FlexDataServer is one ff_data_server4: the device the client reads
// and writes through, in the shape of RFC 8435 section 5.1.
type FlexDataServer struct {
DeviceID [16]byte
Efficiency uint32
Stateid Stateid
// FHs carries one file handle per file handle format the data server
// speaks; one entry is the common case.
FHs [][]byte
User string
Group string
}
// A FlexMirror is one ff_mirror4: a list of equivalent data servers that
// hold the same bytes.
type FlexMirror struct {
DataServers []FlexDataServer
}
// AppendFlexFileLayoutBody encodes the ff_layout4: the stripe unit, the
// mirrors with their data servers, the flags and the stats hint.
func AppendFlexFileLayoutBody(b []byte, flags uint32, stripeUnit uint64, mirrors []FlexMirror) []byte {
b = xdr.AppendUint64(b, stripeUnit)
b = xdr.AppendUint32(b, uint32(len(mirrors)))
for _, m := range mirrors {
b = xdr.AppendUint32(b, uint32(len(m.DataServers)))
for _, ds := range m.DataServers {
b = append(b, ds.DeviceID[:]...)
b = xdr.AppendUint32(b, ds.Efficiency)
b = ds.Stateid.Append(b)
b = xdr.AppendUint32(b, uint32(len(ds.FHs)))
for _, fh := range ds.FHs {
b = xdr.AppendVarOpaque(b, fh)
}
b = xdr.AppendString(b, ds.User)
b = xdr.AppendString(b, ds.Group)
}
}
b = xdr.AppendUint32(b, flags)
return xdr.AppendUint32(b, 0) // stats collect hint
}
// A FlexDeviceAddr is one ff_device_addr4: the net addresses of the data
// servers and the versions they speak.
type FlexDeviceAddr struct {
NetAddrs []NetAddr
Versions []FlexVersion
}
// A NetAddr is one netaddr4 of RFC 5661: a netid and a universal address.
type NetAddr struct {
Netid string
Uaddr string
}
// A FlexVersion is one ff_device_versions4: an NFS version a data server
// speaks and the transfer sizes it allows.
type FlexVersion struct {
Version uint32
MinorVersion uint32
RSize uint32
WSize uint32
TightlyBound bool
}
// AppendFlexDeviceAddr encodes the ff_device_addr4: the net addresses and
// the NFS versions the data servers speak.
func AppendFlexDeviceAddr(b []byte, d FlexDeviceAddr) []byte {
b = xdr.AppendUint32(b, uint32(len(d.NetAddrs)))
for _, na := range d.NetAddrs {
b = xdr.AppendString(b, na.Netid)
b = xdr.AppendString(b, na.Uaddr)
}
b = xdr.AppendUint32(b, uint32(len(d.Versions)))
for _, v := range d.Versions {
b = xdr.AppendUint32(b, v.Version)
b = xdr.AppendUint32(b, v.MinorVersion)
b = xdr.AppendUint32(b, v.RSize)
b = xdr.AppendUint32(b, v.WSize)
b = xdr.AppendBool(b, v.TightlyBound)
}
return b
}
// DecodeFlexDeviceAddr decodes an ff_device_addr4 body.
func DecodeFlexDeviceAddr(body []byte) (FlexDeviceAddr, error) {
d := xdr.NewDecoder(body)
var out FlexDeviceAddr
netCount, err := d.Uint32()
if err != nil {
return out, err
}
for range netCount {
netid, err := d.String()
if err != nil {
return out, err
}
uaddr, err := d.String()
if err != nil {
return out, err
}
out.NetAddrs = append(out.NetAddrs, NetAddr{Netid: netid, Uaddr: uaddr})
}
verCount, err := d.Uint32()
if err != nil {
return out, err
}
for range verCount {
v, err := d.Uint32()
if err != nil {
return out, err
}
minor, err := d.Uint32()
if err != nil {
return out, err
}
rsize, err := d.Uint32()
if err != nil {
return out, err
}
wsize, err := d.Uint32()
if err != nil {
return out, err
}
tight, err := d.Bool()
if err != nil {
return out, err
}
out.Versions = append(out.Versions, FlexVersion{Version: v, MinorVersion: minor, RSize: rsize, WSize: wsize, TightlyBound: tight})
}
return out, nil
}
+546
View File
@@ -0,0 +1,546 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// layoutOf decodes the single segment a test layout response carries.
func layoutOf(t *testing.T, body []byte) (Layout4, []byte) {
t.Helper()
d := xdr.NewDecoder(body)
var st Stateid
raw, err := d.Raw(16)
if err != nil {
t.Fatalf("layout stateid: %v", err)
}
copy(st[:], raw)
roc, err := d.Bool()
if err != nil || roc {
t.Fatalf("return on close: %v %v", roc, err)
}
n, err := d.Uint32()
if err != nil || n != 1 {
t.Fatalf("layout count %d: %v", n, err)
}
var l Layout4
if l.Offset, err = d.Uint64(); err != nil {
t.Fatal(err)
}
if l.Length, err = d.Uint64(); err != nil {
t.Fatal(err)
}
if l.IoMode, err = d.Uint32(); err != nil {
t.Fatal(err)
}
if l.Type, err = d.Uint32(); err != nil {
t.Fatal(err)
}
if l.Body, err = d.VarOpaque(); err != nil {
t.Fatal(err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the layout response", d.Remaining())
}
return l, st[:]
}
func flexBodyOf(t *testing.T, body []byte) (FlexDataServer, uint64) {
t.Helper()
d := xdr.NewDecoder(body)
stripeUnit, err := d.Uint64()
if err != nil {
t.Fatal(err)
}
mirrors, err := d.Uint32()
if err != nil || mirrors != 1 {
t.Fatalf("mirror count %d: %v", mirrors, err)
}
servers, err := d.Uint32()
if err != nil || servers != 1 {
t.Fatalf("data server count %d: %v", servers, err)
}
var ds FlexDataServer
raw, rerr := d.Raw(16)
if rerr != nil {
t.Fatalf("device id: %v", rerr)
}
copy(ds.DeviceID[:], raw)
if ds.Efficiency, err = d.Uint32(); err != nil {
t.Fatal(err)
}
raw, rerr = d.Raw(16)
if rerr != nil {
t.Fatalf("data server stateid: %v", rerr)
}
copy(ds.Stateid[:], raw)
fhCount, ferr := d.Uint32()
if ferr != nil || fhCount != 1 {
t.Fatalf("file handle count %d: %v", fhCount, ferr)
}
var fhBody []byte
if fhBody, err = d.VarOpaque(); err != nil {
t.Fatal(err)
}
ds.FHs = [][]byte{fhBody}
if ds.User, err = d.String(); err != nil {
t.Fatal(err)
}
if ds.Group, err = d.String(); err != nil {
t.Fatal(err)
}
if d.Remaining() != 8 {
t.Fatalf("%d bytes left in the flexfiles body", d.Remaining())
}
return ds, stripeUnit
}
func TestLayoutGetResBodyLen(t *testing.T) {
t.Parallel()
st := Stateid{1, 'L', 'A', 'Y', 1, 2, 3}
fh := []byte("filehandle")
body := AppendFlexFileLayoutBody(nil, 0, 4096, []FlexMirror{{
DataServers: []FlexDataServer{{DeviceID: [16]byte{9}, Stateid: st, FHs: [][]byte{fh}}},
}})
res := AppendLayoutGetRes(nil, st, false, []Layout4{{
Offset: 0, Length: ^uint64(0), IoMode: IoModeRW, Type: LayoutTypeFlexfiles, Body: body,
}})
n, err := resBodyLen(OpLayoutGet, ErrOK, res)
if err != nil {
t.Fatalf("resBodyLen: %v", err)
}
if n != len(res) {
t.Fatalf("measured %d, body is %d", n, len(res))
}
l, _ := layoutOf(t, res)
ds, stripe := flexBodyOf(t, l.Body)
if l.Type != LayoutTypeFlexfiles || l.IoMode != IoModeRW || l.Offset != 0 || l.Length != ^uint64(0) {
t.Fatalf("segment %+v", l)
}
if stripe != 4096 {
t.Fatalf("stripe unit %d", stripe)
}
if ds.DeviceID != [16]byte{9} || len(ds.FHs) != 1 || !bytes.Equal(ds.FHs[0], fh) || ds.Stateid != st {
t.Fatalf("data server %+v", ds)
}
}
func TestLayoutCommitReturnDeviceInfoResBodyLen(t *testing.T) {
t.Parallel()
st := Stateid{2, 'L', 'A', 'Y', 4, 5, 6}
commit := AppendLayoutCommitRes(nil, 101)
if n, err := resBodyLen(OpLayoutCommit, ErrOK, commit); err != nil || n != len(commit) {
t.Fatalf("commit res: %d %v", n, err)
}
ret := AppendLayoutReturnRes(nil, st)
if n, err := resBodyLen(OpLayoutReturn, ErrOK, ret); err != nil || n != len(ret) {
t.Fatalf("return res: %d %v", n, err)
}
addr := AppendFlexDeviceAddr(nil, FlexDeviceAddr{
NetAddrs: []NetAddr{{Netid: "tcp", Uaddr: "127.0.0.1.8.1"}},
Versions: []FlexVersion{{Version: 4, MinorVersion: 2, RSize: 1 << 20, WSize: 1 << 20}},
})
dev := AppendGetDeviceInfoRes(nil, addr)
n, err := resBodyLen(OpGetDeviceInfo, ErrOK, dev)
if err != nil {
t.Fatalf("device info res: %v", err)
}
if n != len(dev) {
t.Fatalf("measured %d, body is %d", n, len(dev))
}
d := xdr.NewDecoder(dev)
if typ, derr := d.Uint32(); derr != nil || typ != LayoutTypeFlexfiles {
t.Fatalf("address type %d: %v", typ, derr)
}
blob, derr := d.VarOpaque()
if derr != nil {
t.Fatal(derr)
}
got, derr := DecodeFlexDeviceAddr(blob)
if derr != nil {
t.Fatalf("decode device address: %v", derr)
}
if len(got.NetAddrs) != 1 || got.NetAddrs[0].Netid != "tcp" || got.NetAddrs[0].Uaddr != "127.0.0.1.8.1" {
t.Fatalf("net addresses %+v", got.NetAddrs)
}
if len(got.Versions) != 1 || got.Versions[0] != (FlexVersion{Version: 4, MinorVersion: 2, RSize: 1 << 20, WSize: 1 << 20}) {
t.Fatalf("versions %+v", got.Versions)
}
if _, derr = ReadBitmap(d); derr != nil {
t.Fatal(derr)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the device info response", d.Remaining())
}
}
func TestLayoutArgShapes(t *testing.T) {
t.Parallel()
st := Stateid{7, 'L', 'A', 'Y', 8, 8, 8}
// LAYOUTGET args carry the op code and the full field set.
args := AppendLayoutGetArgs(nil, false, LayoutTypeFlexfiles, IoModeRW, 0, 100, 100, st, 1<<16)
if got := args[0:4]; !bytes.Equal(got, []byte{0, 0, 0, OpLayoutGet & 0xff}) {
t.Fatalf("layout get args open with % x", got)
}
d := xdr.NewDecoder(args[4:])
if _, err := d.Bool(); err != nil {
t.Fatal(err)
}
if typ, err := d.Uint32(); err != nil || typ != LayoutTypeFlexfiles {
t.Fatalf("type %d: %v", typ, err)
}
if mode, err := d.Uint32(); err != nil || mode != IoModeRW {
t.Fatalf("iomode %d: %v", mode, err)
}
for range 3 {
if _, err := d.Uint64(); err != nil {
t.Fatal(err)
}
}
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if mc, err := d.Uint32(); err != nil || mc != 1<<16 {
t.Fatalf("maxcount %d: %v", mc, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the layout get args", d.Remaining())
}
// The file return carries the range, the stateid and the empty ffsid
// body; the whole client return carries nothing after the header.
fileArgs := AppendLayoutReturnFileArgs(nil, false, LayoutTypeFlexfiles, IoModeRW, 10, 90, st)
d = xdr.NewDecoder(fileArgs[4:])
reclaim, err := d.Bool()
if err != nil || reclaim {
t.Fatalf("reclaim %v: %v", reclaim, err)
}
if typ, err := d.Uint32(); err != nil || typ != LayoutTypeFlexfiles {
t.Fatalf("type %d: %v", typ, err)
}
if mode, err := d.Uint32(); err != nil || mode != IoModeRW {
t.Fatalf("iomode %d: %v", mode, err)
}
if kind, err := d.Uint32(); err != nil || kind != ReturnFile {
t.Fatalf("return kind %d: %v", kind, err)
}
if off, err := d.Uint64(); err != nil || off != 10 {
t.Fatalf("offset %d: %v", off, err)
}
if length, err := d.Uint64(); err != nil || length != 90 {
t.Fatalf("length %d: %v", length, err)
}
raw, err = d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if ffsid, err := d.Uint32(); err != nil || ffsid != 0 {
t.Fatalf("ffsid type %d: %v", ffsid, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the file return", d.Remaining())
}
allArgs := AppendLayoutReturnArgs(nil, true, LayoutTypeFlexfiles, IoModeRW, ReturnAll)
d = xdr.NewDecoder(allArgs[4:])
if reclaim, err := d.Bool(); err != nil || !reclaim {
t.Fatalf("reclaim %v: %v", reclaim, err)
}
for range 3 {
if _, err := d.Uint32(); err != nil {
t.Fatal(err)
}
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the whole client return", d.Remaining())
}
}
func TestLayoutCommitArgsShape(t *testing.T) {
t.Parallel()
st := Stateid{3, 'L', 'A', 'Y', 9, 9, 9}
full := LayoutCommitArgs{
Offset: 8, Length: 92, Reclaim: true, LayoutSt: st,
LastWriteSet: true, LastWrite: 99,
TimeSet: true, Seconds: 1758000000, Nseconds: 500,
UpdateType: 1, UpdateBody: []byte("ff"),
}.Encode()
d := xdr.NewDecoder(full[4:])
if off, err := d.Uint64(); err != nil || off != 8 {
t.Fatalf("offset %d: %v", off, err)
}
if length, err := d.Uint64(); err != nil || length != 92 {
t.Fatalf("length %d: %v", length, err)
}
if reclaim, err := d.Bool(); err != nil || !reclaim {
t.Fatalf("reclaim %v: %v", reclaim, err)
}
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("stateid % x: %v", raw, err)
}
if set, err := d.Bool(); err != nil || !set {
t.Fatalf("last write set %v: %v", set, err)
}
if lw, err := d.Uint64(); err != nil || lw != 99 {
t.Fatalf("last write %d: %v", lw, err)
}
if ts, err := d.Bool(); err != nil || !ts {
t.Fatalf("time set %v: %v", ts, err)
}
if sec, err := d.Int64(); err != nil || sec != 1758000000 {
t.Fatalf("seconds %d: %v", sec, err)
}
if ns, err := d.Uint32(); err != nil || ns != 500 {
t.Fatalf("nseconds %d: %v", ns, err)
}
if ut, err := d.Uint32(); err != nil || ut != 1 {
t.Fatalf("update type %d: %v", ut, err)
}
if body, err := d.VarOpaque(); err != nil || !bytes.Equal(body, []byte("ff")) {
t.Fatalf("update body %q: %v", body, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the commit args", d.Remaining())
}
// The empty form: no last write, no time, no update body.
empty := LayoutCommitArgs{LayoutSt: st}.Encode()
d = xdr.NewDecoder(empty[4:])
for range 2 {
if _, err := d.Uint64(); err != nil {
t.Fatal(err)
}
}
if _, err := d.Bool(); err != nil {
t.Fatal(err)
}
if _, err := d.Raw(16); err != nil {
t.Fatal(err)
}
if set, err := d.Bool(); err != nil || set {
t.Fatalf("empty last write %v: %v", set, err)
}
if ts, err := d.Bool(); err != nil || ts {
t.Fatalf("empty time %v: %v", ts, err)
}
if _, err := d.Uint32(); err != nil {
t.Fatal(err)
}
if body, err := d.VarOpaque(); err != nil || len(body) != 0 {
t.Fatalf("empty update body %q: %v", body, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the empty commit args", d.Remaining())
}
}
func TestGetDeviceInfoArgsShape(t *testing.T) {
t.Parallel()
var device [16]byte
copy(device[:], "0123456789abcdef")
args := AppendGetDeviceInfoArgs(nil, device, LayoutTypeFlexfiles, 1<<16, Bitmap{}.With(0))
d := xdr.NewDecoder(args[4:])
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, device[:]) {
t.Fatalf("device id % x: %v", raw, err)
}
if typ, err := d.Uint32(); err != nil || typ != LayoutTypeFlexfiles {
t.Fatalf("type %d: %v", typ, err)
}
if mc, err := d.Uint32(); err != nil || mc != 1<<16 {
t.Fatalf("maxcount %d: %v", mc, err)
}
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the device info args", d.Remaining())
}
}
func TestOps41Shapes(t *testing.T) {
t.Parallel()
// VERIFY args are a bare fattr4 with the op in front.
args := AppendVerifyArgs(nil, OfBits(AttrMode), Attrs{Mode: 0o644})
d := xdr.NewDecoder(args[4:])
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if _, err := d.VarOpaque(); err != nil {
t.Fatal(err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the verify args", d.Remaining())
}
// BIND_CONN_TO_SESSION echoes the session and the direction; its
// result body measures to exactly 20 bytes.
sid := SessionID{1, 2, 3}
res := AppendBindConnToSessionRes(nil, sid, Cdfs4Both)
if n, err := resBodyLen(OpBindConnToSession, ErrOK, res); err != nil || n != len(res) || n != 20 {
t.Fatalf("bind res measured %d of %d: %v", n, len(res), err)
}
d = xdr.NewDecoder(res)
raw, err := d.Raw(16)
if err != nil || !bytes.Equal(raw, sid[:]) {
t.Fatalf("session % x: %v", raw, err)
}
if dir, err := d.Uint32(); err != nil || dir != Cdfs4Both {
t.Fatalf("dir %d: %v", dir, err)
}
// TEST_STATEID measures to the status array.
statuses := AppendTestStateidRes(nil, []uint32{ErrOK, ErrBadStateid})
if n, err := resBodyLen(OpTestStateid, ErrOK, statuses); err != nil || n != len(statuses) {
t.Fatalf("test stateid res measured %d of %d: %v", n, len(statuses), err)
}
// RELEASE_LOCKOWNER and DELEGRETURN carry their shapes.
own := AppendReleaseLockOwnerArgs(nil, 0x99, []byte("owner"))
d = xdr.NewDecoder(own[4:])
if cid, err := d.Uint64(); err != nil || cid != 0x99 {
t.Fatalf("clientid %d: %v", cid, err)
}
if o, err := d.VarOpaque(); err != nil || string(o) != "owner" {
t.Fatalf("owner %q: %v", o, err)
}
st := Stateid{1, 'D', 'E', 'L', 7}
dr := AppendDelegReturnArgs(nil, st)
d = xdr.NewDecoder(dr[4:])
raw, err = d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("deleg return stateid % x: %v", raw, err)
}
fs := AppendFreeStateidArgs(nil, st)
d = xdr.NewDecoder(fs[4:])
raw, err = d.Raw(16)
if err != nil || !bytes.Equal(raw, st[:]) {
t.Fatalf("free stateid % x: %v", raw, err)
}
bc := AppendBackchannelCtlArgs(nil, 0x40000000)
d = xdr.NewDecoder(bc[4:])
if p, err := d.Uint32(); err != nil || p != 0x40000000 {
t.Fatalf("program %d: %v", p, err)
}
ts := AppendTestStateidArgs(nil, []Stateid{st, {}})
d = xdr.NewDecoder(ts[4:])
if n, err := d.Uint32(); err != nil || n != 2 {
t.Fatalf("count %d: %v", n, err)
}
for range 2 {
raw, err = d.Raw(16)
if err != nil {
t.Fatal(err)
}
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the test stateid args", d.Remaining())
}
}
func TestXattrShapes(t *testing.T) {
t.Parallel()
// GETXATTR args carry the name alone, RFC 8276: the operation
// number, the length word and the twelve byte name.
args := AppendGetXattrArgs(nil, "user.comment")
if len(args) != 20 {
t.Fatalf("getxattr args of %d bytes, want 20", len(args))
}
d := xdr.NewDecoder(args[4:])
if name, err := d.String(); err != nil || name != "user.comment" {
t.Fatalf("name %q: %v", name, err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the getxattr args", d.Remaining())
}
res := AppendGetXattrRes(nil, []byte("hello"))
n, err := resBodyLen(OpGetxattr, ErrOK, res)
if err != nil || n != len(res) {
t.Fatalf("getxattr res measured %d of %d: %v", n, len(res), err)
}
if v, err := DecodeGetXattrRes(res); err != nil || string(v) != "hello" {
t.Fatalf("getxattr value %q: %v", v, err)
}
// LISTXATTR res: the cookie, the names and the eof flag.
list := AppendListXattrRes(nil, 7, []string{"user.a", "user.b"}, false)
n, err = resBodyLen(OpListxattr, ErrOK, list)
if err != nil || n != len(list) {
t.Fatalf("listxattr res measured %d of %d: %v", n, len(list), err)
}
// SETXATTR args: the mode first, then the key and the value.
sx := AppendSetXattrArgs(nil, SetXattrCreate, "user.a", []byte("v"))
d = xdr.NewDecoder(sx[4:])
if mode, err := d.Uint32(); err != nil || mode != SetXattrCreate {
t.Fatalf("mode %d: %v", mode, err)
}
if name, err := d.String(); err != nil || name != "user.a" {
t.Fatalf("set name %q: %v", name, err)
}
if v, err := d.VarOpaque(); err != nil || string(v) != "v" {
t.Fatalf("value %q: %v", v, err)
}
rx := AppendRemoveXattrArgs(nil, "user.a")
d = xdr.NewDecoder(rx[4:])
if name, err := d.String(); err != nil || name != "user.a" {
t.Fatalf("remove name %q: %v", name, err)
}
}
func TestOps41RemainingShapes(t *testing.T) {
t.Parallel()
// The argument-less ops carry exactly the op code.
if got := AppendLookupp(nil); !bytes.Equal(got, []byte{0, 0, 0, OpLookupp & 0xff}) {
t.Fatalf("lookupp % x", got)
}
if got := AppendPutPubfh(nil); !bytes.Equal(got, []byte{0, 0, 0, OpPutPubfh & 0xff}) {
t.Fatalf("putpubfh % x", got)
}
// NVERIFY shares the VERIFY shape.
nv := AppendNverifyArgs(nil, OfBits(AttrSize), Attrs{Size: 9})
d := xdr.NewDecoder(nv[4:])
if _, err := ReadBitmap(d); err != nil {
t.Fatal(err)
}
if _, err := d.VarOpaque(); err != nil {
t.Fatal(err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the nverify args", d.Remaining())
}
// BIND_CONN_TO_SESSION args: the session, the direction and the
// RDMA mode flag.
bind := AppendBindConnToSessionArgs(nil, SessionID{9}, Cdfc4Back, true)
d = xdr.NewDecoder(bind[4:])
raw, err := d.Raw(16)
if err != nil || raw[0] != 9 {
t.Fatalf("session % x: %v", raw, err)
}
if dir, err := d.Uint32(); err != nil || dir != Cdfc4Back {
t.Fatalf("dir %d: %v", dir, err)
}
if rdma, err := d.Bool(); err != nil || !rdma {
t.Fatalf("rdma %v: %v", rdma, err)
}
// LISTXATTR args carry the cookie and the budget.
la := AppendListXattrArgs(nil, 3, 4096)
d = xdr.NewDecoder(la[4:])
if ck, err := d.Uint64(); err != nil || ck != 3 {
t.Fatalf("list cookie %d: %v", ck, err)
}
if mc, err := d.Uint32(); err != nil || mc != 4096 {
t.Fatalf("list maxcount %d: %v", mc, err)
}
}
+423
View File
@@ -0,0 +1,423 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The session establishment layer of RFC 8881 sections 18.35 to 18.37 and
// 18.46: EXCHANGE_ID names the client, CREATE_SESSION makes the session
// with its slot table, SEQUENCE drives the slots, DESTROY_SESSION tears
// one down.
package nfs4
import (
"encoding/binary"
"errors"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// EXCHGID4_FLAG values, RFC 8881 section 18.35.
const (
ExchgIDSuppMovedRefer = 0x00000001
ExchgIDSuppMovedMigr = 0x00000002
ExchgIDSuppFenceOps = 0x00000004
ExchgIDBindPrincStateid = 0x00000100
ExchgIDUseNonPnfs = 0x00010000
ExchgIDUsePnfsMds = 0x00020000
ExchgIDUsePnfsDs = 0x00040000
ExchgIDConfirmedR = 0x80000000
)
// CREATE_SESSION4_FLAG values.
const (
CreateSessionFlagPersist = 0x00000001
CreateSessionFlagConnBackChan = 0x00000002
CreateSessionFlagConnRdma = 0x00000004
)
// state protection choices of the EXCHANGE_ID unions, the
// state_protect_how4 enumeration of RFC 8881 section 18.35. Only the
// plain SP4_NONE is spoken by this build.
const (
StateProtectNone = 0 // SP4_NONE
StateProtectMachCred = 1 // SP4_MACH_CRED
StateProtectSSV = 2 // SP4_SSV
)
// A SessionID is the 16 byte session identifier RFC 7863 fixes
// (NFS4_SESSIONID_SIZE): an 8 byte server prefix followed by the 8 byte
// client id, both chosen by the server.
type SessionID [16]byte
// ClientIDOf splits the session id into its client id half.
func (s SessionID) ClientIDOf() uint64 {
return binary.BigEndian.Uint64(s[8:16])
}
// MakeSessionID builds a session id from the server prefix and the client
// id.
func MakeSessionID(prefix [8]byte, clientid uint64) SessionID {
var s SessionID
copy(s[:8], prefix[:])
binary.BigEndian.PutUint64(s[8:], clientid)
return s
}
// MakeNumberedSessionID builds a session id whose server half carries a
// per-session number, so every CREATE_SESSION mints a distinct id as
// RFC 8881 section 18.36 requires: four prefix bytes, the number and
// the client id.
func MakeNumberedSessionID(prefix [4]byte, number uint32, clientid uint64) SessionID {
var s SessionID
copy(s[:4], prefix[:])
binary.BigEndian.PutUint32(s[4:], number)
binary.BigEndian.PutUint64(s[8:], clientid)
return s
}
// A ChannelAttrs is the channel_attrs4 of a session: the sizes and limits
// of one connection direction.
type ChannelAttrs struct {
HeaderPad uint32
MaxRequest uint32
MaxResponse uint32
MaxRespResourced uint32
MaxOperations uint32
MaxRequests uint32
RdmaIRDEnabled bool
RdmaIRSizes []uint32
}
// DefaultForeChannel is the fore channel this server grants: enough slots
// for a real client, bounded to what one connection streams.
var DefaultForeChannel = ChannelAttrs{
MaxRequest: 1 << 22,
MaxResponse: 1 << 22,
MaxRespResourced: 1 << 22,
MaxOperations: 16,
MaxRequests: 8,
}
// DefaultBackChannel declares the minimal back channel: one operation and
// one request, with no channel attributes, which the client reads as
// nothing offered.
var DefaultBackChannel = ChannelAttrs{MaxRequest: 1 << 20, MaxResponse: 1 << 20, MaxRespResourced: 1 << 20, MaxOperations: 1, MaxRequests: 1}
// ErrOpMisordered marks a SEQUENCE that arrived on a slot with a sequence
// that is neither a retry nor the next one.
var ErrOpMisordered = errors.New("nfs4: sequence misordered")
// ExchangeIDArgs is the decoded EXCHANGE_ID4args.
type ExchangeIDArgs struct {
Verifier [8]byte
OwnerID []byte
Flags uint32
Protect uint32
}
// AppendExchangeIDArgs encodes the EXCHANGE_ID4args. The implementation id
// array is sent empty.
func AppendExchangeIDArgs(b []byte, verifier [8]byte, ownerID []byte, flags uint32) []byte {
b = xdr.AppendUint32(b, OpExchangeID)
b = append(b, verifier[:]...)
b = xdr.AppendVarOpaque(b, ownerID)
b = xdr.AppendUint32(b, flags)
b = xdr.AppendUint32(b, StateProtectNone)
return xdr.AppendUint32(b, 0) // empty eia_client_impl_id
}
// DecodeExchangeIDArgs decodes the EXCHANGE_ID4args from the decoder,
// which is positioned after the operation number.
func DecodeExchangeIDArgs(d *xdr.Decoder) (ExchangeIDArgs, error) {
var a ExchangeIDArgs
verf, err := d.Raw(8)
if err != nil {
return a, err
}
copy(a.Verifier[:], verf)
if a.OwnerID, err = d.VarOpaque(); err != nil {
return a, err
}
if a.Flags, err = d.Uint32(); err != nil {
return a, err
}
if a.Protect, err = d.Uint32(); err != nil {
return a, err
}
if a.Protect != StateProtectNone {
return a, ErrStateProtectNotSupp
}
// The client implementation id array is walked and ignored.
n, err := d.Uint32()
if err != nil {
return a, err
}
for range n {
if _, err = d.String(); err != nil {
return a, err
}
if _, err = d.String(); err != nil {
return a, err
}
if _, err = d.Uint64(); err != nil {
return a, err
}
if _, err = d.Uint32(); err != nil {
return a, err
}
}
return a, nil
}
// AppendExchangeIDRes encodes a successful EXCHANGE_ID4res: the client id,
// the create session sequence, the flags, the plain state protection, the
// server owner, the server scope and one implementation id entry, RFC 8881
// section 18.35. The field order and the entry shape follow what real
// clients decode: the minor id is a uint64 and the major id a bounded
// opaque, the scope follows the owner, and the entries carry no dummy.
func AppendExchangeIDRes(b []byte, clientid uint64, sequence, flags uint32, majorID []byte) []byte {
b = xdr.AppendUint64(b, clientid)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, flags)
b = xdr.AppendUint32(b, StateProtectNone)
// eir_server_owner: the major id stays constant across restarts so
// the client recognises the server.
b = xdr.AppendUint64(b, 0)
b = xdr.AppendVarOpaque(b, majorID)
// eir_server_scope: the servers that share this one's state. The
// Linux client fails the exchange when the field is missing.
b = xdr.AppendVarOpaque(b, majorID)
// eir_server_impl_id: one entry of domain, name and the zeroed date.
b = xdr.AppendUint32(b, 1)
b = xdr.AppendString(b, "sourcedock.dev")
b = xdr.AppendString(b, "nfsd")
b = xdr.AppendInt64(b, 0)
b = xdr.AppendUint32(b, 0)
return b
}
// AppendSessionChannel encodes a channel_attrs4.
func AppendSessionChannel(b []byte, c ChannelAttrs) []byte {
b = xdr.AppendUint32(b, c.HeaderPad)
b = xdr.AppendUint32(b, c.MaxRequest)
b = xdr.AppendUint32(b, c.MaxResponse)
b = xdr.AppendUint32(b, c.MaxRespResourced)
b = xdr.AppendUint32(b, c.MaxOperations)
b = xdr.AppendUint32(b, c.MaxRequests)
if c.RdmaIRDEnabled {
b = xdr.AppendUint32(b, uint32(len(c.RdmaIRSizes)))
for _, v := range c.RdmaIRSizes {
b = xdr.AppendUint32(b, v)
}
return b
}
// ca_rdma_ird is a counted array, RFC 8881 section 18.36: an empty
// one counts zero. Every real client decoder rejects anything else;
// the Linux client answers EINVAL for a count above one.
return xdr.AppendUint32(b, 0)
}
// DecodeSessionChannel decodes a channel_attrs4 body.
func DecodeSessionChannel(d *xdr.Decoder) (ChannelAttrs, error) {
var c ChannelAttrs
var err error
if c.HeaderPad, err = d.Uint32(); err != nil {
return c, err
}
if c.MaxRequest, err = d.Uint32(); err != nil {
return c, err
}
if c.MaxResponse, err = d.Uint32(); err != nil {
return c, err
}
if c.MaxRespResourced, err = d.Uint32(); err != nil {
return c, err
}
if c.MaxOperations, err = d.Uint32(); err != nil {
return c, err
}
if c.MaxRequests, err = d.Uint32(); err != nil {
return c, err
}
count, err := d.Uint32()
if err != nil {
return c, err
}
// ca_rdma_ird is a counted array: zero means no RDMA IRD support.
if count > 0 {
c.RdmaIRDEnabled = true
for range count {
v, verr := d.Uint32()
if verr != nil {
return c, verr
}
c.RdmaIRSizes = append(c.RdmaIRSizes, v)
}
}
return c, nil
}
// AppendCreateSessionArgs encodes the CREATE_SESSION4args.
func AppendCreateSessionArgs(b []byte, clientid uint64, sequence, flags uint32, fore, back ChannelAttrs, cbProgram uint32) []byte {
b = xdr.AppendUint32(b, OpCreateSession)
b = xdr.AppendUint64(b, clientid)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, flags)
b = AppendSessionChannel(b, fore)
b = AppendSessionChannel(b, back)
return xdr.AppendUint32(b, cbProgram)
}
// A CreateSessionArgs is the decoded CREATE_SESSION4args.
type CreateSessionArgs struct {
ClientID uint64
Sequence uint32
Flags uint32
Fore ChannelAttrs
Back ChannelAttrs
CBProgram uint32
}
// DecodeCreateSessionArgs decodes the CREATE_SESSION4args from the
// decoder, which is positioned after the operation number.
func DecodeCreateSessionArgs(d *xdr.Decoder) (CreateSessionArgs, error) {
var a CreateSessionArgs
var err error
if a.ClientID, err = d.Uint64(); err != nil {
return a, err
}
if a.Sequence, err = d.Uint32(); err != nil {
return a, err
}
if a.Flags, err = d.Uint32(); err != nil {
return a, err
}
if a.Fore, err = DecodeSessionChannel(d); err != nil {
return a, err
}
if a.Back, err = DecodeSessionChannel(d); err != nil {
return a, err
}
if a.CBProgram, err = d.Uint32(); err != nil {
return a, err
}
return a, nil
}
// AppendCreateSessionRes encodes a successful CREATE_SESSION4res.
func AppendCreateSessionRes(b []byte, id SessionID, sequence, flags uint32, fore, back ChannelAttrs) []byte {
b = append(b, id[:]...)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, flags)
b = AppendSessionChannel(b, fore)
return AppendSessionChannel(b, back)
}
// AppendDestroySessionArgs appends the DESTROY_SESSION argop.
func AppendDestroySessionArgs(b []byte, id SessionID) []byte {
b = xdr.AppendUint32(b, OpDestroySession)
return append(b, id[:]...)
}
// AppendSequenceArgs appends the SEQUENCE argop.
func AppendSequenceArgs(b []byte, id SessionID, sequence, slot, highestSlot uint32, cacheThis bool) []byte {
b = xdr.AppendUint32(b, OpSequence)
b = append(b, id[:]...)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, slot)
b = xdr.AppendUint32(b, highestSlot)
return xdr.AppendBool(b, cacheThis)
}
// A SequenceArgs is the decoded SEQUENCE4args.
type SequenceArgs struct {
SessionID SessionID
Sequence uint32
Slot uint32
HighestSlot uint32
CacheThis bool
}
// DecodeSequenceArgs decodes the SEQUENCE4args from the decoder, which
// is positioned after the operation number.
func DecodeSequenceArgs(d *xdr.Decoder) (SequenceArgs, error) {
var a SequenceArgs
raw, err := d.Raw(16)
if err != nil {
return a, err
}
copy(a.SessionID[:], raw)
if a.Sequence, err = d.Uint32(); err != nil {
return a, err
}
if a.Slot, err = d.Uint32(); err != nil {
return a, err
}
if a.HighestSlot, err = d.Uint32(); err != nil {
return a, err
}
if a.CacheThis, err = d.Bool(); err != nil {
return a, err
}
return a, nil
}
// NegotiateChannel clamps the requested channel attributes to the
// server's limits, RFC 8881 section 18.36: every negotiated value is the
// smaller of the request and the limit. A zero request carries meaning,
// ca_maxresponsesize_cached of zero above all, so it is answered with
// zero. Real clients reject a reply that exceeds their request.
func NegotiateChannel(requested ChannelAttrs, limits ChannelAttrs) ChannelAttrs {
nv := func(requested, limit uint32) uint32 {
if requested > limit {
return limit
}
return requested
}
return ChannelAttrs{
MaxRequest: nv(requested.MaxRequest, limits.MaxRequest),
MaxResponse: nv(requested.MaxResponse, limits.MaxResponse),
MaxRespResourced: nv(requested.MaxRespResourced, limits.MaxRespResourced),
MaxOperations: nv(requested.MaxOperations, limits.MaxOperations),
MaxRequests: nv(requested.MaxRequests, limits.MaxRequests),
}
}
// AppendSequenceRes encodes the successful SEQUENCE4resok of RFC 7863:
// the session id, the echoed identifiers and the status flags. The
// target highest slot mirrors the highest slot this build grants; a
// caller with its own view passes it instead.
func AppendSequenceRes(b []byte, id SessionID, sequence, slot, highestSlot, statusFlags uint32) []byte {
b = append(b, id[:]...)
b = xdr.AppendUint32(b, sequence)
b = xdr.AppendUint32(b, slot)
b = xdr.AppendUint32(b, highestSlot)
b = xdr.AppendUint32(b, highestSlot) // sr_target_highest_slotid
return xdr.AppendUint32(b, statusFlags)
}
// DecodeSequenceRes decodes the successful SEQUENCE4resok.
func DecodeSequenceRes(d *xdr.Decoder) (id SessionID, sequence, slot, highestSlot, targetHighestSlot, statusFlags uint32, err error) {
raw, err := d.Raw(16)
if err != nil {
return
}
copy(id[:], raw)
if sequence, err = d.Uint32(); err != nil {
return
}
if slot, err = d.Uint32(); err != nil {
return
}
if highestSlot, err = d.Uint32(); err != nil {
return
}
if targetHighestSlot, err = d.Uint32(); err != nil {
return
}
statusFlags, err = d.Uint32()
return
}
// ErrStateProtectNotSupp marks an EXCHANGE_ID that named a state
// protection this build does not speak.
var ErrStateProtectNotSupp = errors.New("nfs4: state protection not supported")
+317
View File
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestSessionIDShape(t *testing.T) {
// The session id is sixteen bytes on the wire, RFC 7863: an eight
// byte server prefix and the eight byte client id.
var prefix [8]byte
copy(prefix[:], []byte("nfs-srv-"))
id := MakeSessionID(prefix, 0xdeadbeefcafe)
if len(id) != 16 {
t.Fatalf("session id of %d bytes, want 16", len(id))
}
if id.ClientIDOf() != 0xdeadbeefcafe {
t.Fatalf("client id half: %x", id.ClientIDOf())
}
if !bytes.Equal(id[:8], prefix[:]) {
t.Fatalf("server prefix: %x", id[:8])
}
}
func TestExchangeIDRoundTrip(t *testing.T) {
var verifier [8]byte
copy(verifier[:], []byte("verif-1-"))
arg := AppendExchangeIDArgs(nil, verifier, []byte("owner-1"), ExchgIDUseNonPnfs)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpExchangeID {
t.Fatalf("op: %d, %v", op, err)
}
a, err := DecodeExchangeIDArgs(d)
if err != nil {
t.Fatalf("decode: %v", err)
}
if a.Verifier != verifier || string(a.OwnerID) != "owner-1" ||
a.Flags != ExchgIDUseNonPnfs || a.Protect != StateProtectNone {
t.Fatalf("args: %+v", a)
}
res := AppendExchangeIDRes(nil, 77, 0, ExchgIDUseNonPnfs|ExchgIDConfirmedR, []byte("major"))
rd := xdr.NewDecoder(res)
if clientid, err := rd.Uint64(); err != nil || clientid != 77 {
t.Fatalf("clientid: %d, %v", clientid, err)
}
if seq, err := rd.Uint32(); err != nil || seq != 0 {
t.Fatalf("sequence: %d, %v", seq, err)
}
if flags, err := rd.Uint32(); err != nil || flags&ExchgIDConfirmedR == 0 {
t.Fatalf("flags: %x, %v", flags, err)
}
if n, err := resBodyLen(OpExchangeID, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("measure: %d of %d, %v", n, len(res), err)
}
}
func TestCreateSessionRoundTrip(t *testing.T) {
arg := AppendCreateSessionArgs(nil, 0x1234, 3, CreateSessionFlagPersist,
DefaultForeChannel, DefaultBackChannel, 0xcafe)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpCreateSession {
t.Fatalf("op: %d, %v", op, err)
}
a, err := DecodeCreateSessionArgs(d)
if err != nil {
t.Fatalf("decode: %v", err)
}
if a.ClientID != 0x1234 || a.Sequence != 3 || a.Flags != CreateSessionFlagPersist ||
a.Fore.MaxRequest != DefaultForeChannel.MaxRequest ||
a.Fore.MaxRequests != DefaultForeChannel.MaxRequests ||
a.Back.MaxRequest != DefaultBackChannel.MaxRequest ||
a.CBProgram != 0xcafe {
t.Fatalf("args: %+v", a)
}
id := MakeSessionID([8]byte{1, 2, 3, 4, 5, 6, 7, 8}, 0x1234)
res := AppendCreateSessionRes(nil, id, 3, 0, DefaultForeChannel, DefaultBackChannel)
if n, err := resBodyLen(OpCreateSession, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("measure: %d of %d, %v", n, len(res), err)
}
}
func TestSequenceAndDestroyWire(t *testing.T) {
id := MakeSessionID([8]byte{9, 9, 9, 9, 9, 9, 9, 9}, 42)
arg := AppendSequenceArgs(nil, id, 5, 1, 7, true)
a, err := DecodeSequenceArgs(xdr.NewDecoder(arg[4:]))
if err != nil {
t.Fatalf("decode: %v", err)
}
if a.SessionID != id || a.Sequence != 5 || a.Slot != 1 ||
a.HighestSlot != 7 || !a.CacheThis {
t.Fatalf("args: %+v", a)
}
// The result carries exactly the five words after the session id
// that RFC 7863 fixes: sequence, slot, highest, target, flags.
res := AppendSequenceRes(nil, id, 5, 1, 7, 0)
if len(res) != 36 {
t.Fatalf("sequence result of %d bytes, want 36", len(res))
}
if n, err := resBodyLen(OpSequence, ErrOK, res); err != nil || n != 36 {
t.Fatalf("sequence measure: %d, %v", n, err)
}
flags, err := xdr.NewDecoder(res[32:]).Uint32()
if err != nil || flags != 0 {
t.Fatalf("sr_status_flags: %d, %v", flags, err)
}
sid, seq, slot, high, target, statusFlags, err := DecodeSequenceRes(xdr.NewDecoder(res))
if err != nil || sid != id || seq != 5 || slot != 1 || high != 7 ||
target != 7 || statusFlags != 0 {
t.Fatalf("decode res: %d %d %d %d %d %d, %v", seq, slot, high, target, statusFlags, 0, err)
}
destroy := AppendDestroySessionArgs(nil, id)
d := xdr.NewDecoder(destroy)
if op, err := d.Uint32(); err != nil || op != OpDestroySession {
t.Fatalf("destroy op: %d, %v", op, err)
}
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, id[:]) {
t.Fatalf("destroy id: %x, %v", raw, err)
}
}
func TestExchangeIDImplArray(t *testing.T) {
// One implementation entry walks exactly its three fields: domain,
// name and the date, the shape RFC 7863 fixes. A trailing dummy
// word stays in the stream and is the next operation's business.
var blob []byte
blob = append(blob, make([]byte, 8)...) // verifier
blob = xdr.AppendVarOpaque(blob, []byte("id")) // owner
blob = xdr.AppendUint32(blob, 0) // flags
blob = xdr.AppendUint32(blob, StateProtectNone)
blob = xdr.AppendUint32(blob, 1) // one impl entry
blob = xdr.AppendString(blob, "example.org")
blob = xdr.AppendString(blob, "probe")
blob = xdr.AppendUint64(blob, 0) // date seconds
blob = xdr.AppendUint32(blob, 0) // date nanoseconds
d := xdr.NewDecoder(blob)
a, err := DecodeExchangeIDArgs(d)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(a.OwnerID) != "id" || d.Remaining() != 0 {
t.Fatalf("owner %q, %d bytes left", a.OwnerID, d.Remaining())
}
// The word some clients append behind the entry is not consumed by
// this decoder; the compound walk sees it as the next argop.
withExtra := append(append([]byte{}, blob...), 0, 0, 0, 0)
d = xdr.NewDecoder(withExtra)
if _, err := DecodeExchangeIDArgs(d); err != nil || d.Remaining() != 4 {
t.Fatalf("decode with extra: %v, %d bytes left", err, d.Remaining())
}
}
func TestDecodeSessionChannelIrd(t *testing.T) {
// A channel with the RDMA sizes present walks them all.
var body []byte
body = xdr.AppendUint32(body, 0) // header pad
body = xdr.AppendUint32(body, 1<<20) // max request
body = xdr.AppendUint32(body, 1<<20) // max response
body = xdr.AppendUint32(body, 1<<20) // resourced
body = xdr.AppendUint32(body, 4) // max operations
body = xdr.AppendUint32(body, 2) // max requests
body = xdr.AppendUint32(body, 2) // two rdma ird sizes
body = xdr.AppendUint32(body, 1)
body = xdr.AppendUint32(body, 4)
c, err := DecodeSessionChannel(xdr.NewDecoder(body))
if err != nil {
t.Fatalf("decode: %v", err)
}
if !c.RdmaIRDEnabled || len(c.RdmaIRSizes) != 2 || c.RdmaIRSizes[1] != 4 ||
c.MaxRequests != 2 {
t.Fatalf("channel: %+v", c)
}
}
func TestExchangeIDStateProtectReject(t *testing.T) {
// A state protection other than the plain none is refused with a
// dedicated error, because this build does not speak RPCSEC_GSS
// state protection yet.
var blob []byte
blob = append(blob, make([]byte, 8)...)
blob = xdr.AppendVarOpaque(blob, []byte("id"))
blob = xdr.AppendUint32(blob, 0)
blob = xdr.AppendUint32(blob, StateProtectMachCred)
if _, err := DecodeExchangeIDArgs(xdr.NewDecoder(blob)); err == nil {
t.Fatal("an unsupported state protection decoded")
}
}
func TestOpenCloseWire(t *testing.T) {
arg := AppendOpenArgs(nil, 0xfeed, []byte("owner"), ShareAccessBoth, uint32(0),
true, 0o640, "file.txt")
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpOpen {
t.Fatalf("op: %d, %v", op, err)
}
a, err := DecodeOpenArgs(d)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !a.Create || a.Access != ShareAccessBoth || a.Deny != uint32(0) ||
a.ClientID != 0xfeed || string(a.Owner) != "owner" || a.Perm != 0o640 ||
a.Name != "file.txt" {
t.Fatalf("open args: %+v", a)
}
// NOCREATE: the create switch is absent.
arg = AppendOpenArgs(nil, 1, []byte("o"), ShareAccessRead, 0, false, 0, "there.txt")
a, err = DecodeOpenArgs(xdr.NewDecoder(arg[4:]))
if err != nil || a.Create || a.Name != "there.txt" {
t.Fatalf("nocreate args: %+v, %v", a, err)
}
res := AppendOpenRes(nil, AllZero)
if n, err := resBodyLen(OpOpen, ErrOK, res); err != nil || n != len(res) {
t.Fatalf("open measure: %d of %d, %v", n, len(res), err)
}
closeArg := AppendCloseArgs(nil, AllZero)
d = xdr.NewDecoder(closeArg)
if op, err := d.Uint32(); err != nil || op != OpClose {
t.Fatalf("close op: %d, %v", op, err)
}
if seqid, err := d.Uint32(); err != nil || seqid != 0 {
t.Fatalf("close seqid: %d, %v", seqid, err)
}
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, AllZero[:]) {
t.Fatalf("close stateid: %x, %v", raw, err)
}
closeRes := AppendCloseRes(nil, AllZero)
if n, err := resBodyLen(OpClose, ErrOK, closeRes); err != nil || n != 16 {
t.Fatalf("close measure: %d, %v", n, err)
}
}
func TestOpenDowngradeDestroyReclaimWire(t *testing.T) {
// OPEN_DOWNGRADE args: seqid, stateid, access, deny.
arg := AppendOpenDowngradeArgs(nil, AllZero, ShareAccessRead, 0)
d := xdr.NewDecoder(arg)
if op, err := d.Uint32(); err != nil || op != OpOpenDowngrade {
t.Fatalf("op: %d, %v", op, err)
}
if seqid, err := d.Uint32(); err != nil || seqid != 0 {
t.Fatalf("seqid: %d, %v", seqid, err)
}
if raw, err := d.Raw(16); err != nil || !bytes.Equal(raw, AllZero[:]) {
t.Fatalf("stateid: %x, %v", raw, err)
}
if access, err := d.Uint32(); err != nil || access != ShareAccessRead {
t.Fatalf("access: %v", err)
}
if deny, err := d.Uint32(); err != nil || deny != 0 {
t.Fatalf("deny: %v", err)
}
dgRes := AppendOpenDowngradeRes(nil)
if n, err := resBodyLen(OpOpenDowngrade, ErrOK, dgRes); err != nil || n != 20 {
t.Fatalf("downgrade measure: %d, %v", n, err)
}
// DESTROY_CLIENTID args and its empty result body.
dc := AppendDestroyClientIDArgs(nil, 0x1234)
d = xdr.NewDecoder(dc)
if op, err := d.Uint32(); err != nil || op != OpDestroyClientID {
t.Fatalf("destroy op: %d, %v", op, err)
}
if cid, err := d.Uint64(); err != nil || cid != 0x1234 {
t.Fatalf("clientid: %v", err)
}
if n, err := resBodyLen(OpDestroyClientID, ErrOK, nil); err != nil || n != 0 {
t.Fatalf("destroy measure: %d, %v", n, err)
}
// RECLAIM_COMPLETE args: the one client flag alone, RFC 7863.
rc := AppendReclaimCompleteArgs(nil, false)
d = xdr.NewDecoder(rc)
if op, err := d.Uint32(); err != nil || op != OpReclaimComplete {
t.Fatalf("op: %d, %v", op, err)
}
if one, err := d.Bool(); err != nil || one {
t.Fatalf("one_client: %v", err)
}
if d.Remaining() != 0 {
t.Fatalf("%d bytes left in the reclaim args", d.Remaining())
}
if n, err := resBodyLen(OpReclaimComplete, ErrOK, nil); err != nil || n != 0 {
t.Fatalf("reclaim measure: %d, %v", n, err)
}
}
func TestAppendOpenResDelegLength(t *testing.T) {
// The write delegation carries the space limit union and the
// permissions ace on top of the read form, RFC 7863: the whole
// successful OPEN result measures 96 bytes with one.
var st Stateid
st[0] = 1
res := AppendOpenResDeleg(nil, st, OpenDelegWrite, st)
if len(res) != 96 {
t.Fatalf("res %d bytes, want 96", len(res))
}
// The read form ends after the ace: 84 bytes.
res = AppendOpenResDeleg(nil, st, OpenDelegRead, st)
if len(res) != 84 {
t.Fatalf("read res %d bytes, want 84", len(res))
}
if _, _, _, err := DecodeOpenResDeleg(res); err != nil {
t.Fatalf("read round trip: %v", err)
}
}
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("0000\x00\x00\x00\x00\x00\x00\x000\x00\x00\x00)\x00\x00\x00\x00")
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The extended attribute operations of RFC 8276: four narrow operations
// over named opaque values.
package nfs4
import (
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// SETXATTR modes of the setxattr4mode4 enum.
const (
SetXattrUnchecked = 0
SetXattrCreate = 1
SetXattrReplace = 2
)
// AppendGetXattrArgs appends the GETXATTR argop: the name alone, the
// whole argument RFC 8276 defines.
func AppendGetXattrArgs(b []byte, name string) []byte {
b = xdr.AppendUint32(b, OpGetxattr)
return xdr.AppendString(b, name)
}
// AppendGetXattrRes appends a successful GETXATTR result: the value as
// one opaque.
func AppendGetXattrRes(b []byte, value []byte) []byte {
return xdr.AppendVarOpaque(b, value)
}
// DecodeGetXattrRes decodes a successful GETXATTR result.
func DecodeGetXattrRes(body []byte) ([]byte, error) {
return xdr.NewDecoder(body).VarOpaque()
}
// AppendSetXattrArgs appends the SETXATTR argop: the create mode, the
// name and the value.
func AppendSetXattrArgs(b []byte, mode uint32, name string, value []byte) []byte {
b = xdr.AppendUint32(b, OpSetxattr)
b = xdr.AppendUint32(b, mode)
b = xdr.AppendString(b, name)
return xdr.AppendVarOpaque(b, value)
}
// AppendListXattrArgs appends the LISTXATTR argop: the cookie the last
// reply returned and the response size budget.
func AppendListXattrArgs(b []byte, cookie uint64, maxcount uint32) []byte {
b = xdr.AppendUint32(b, OpListxattr)
b = xdr.AppendUint64(b, cookie)
return xdr.AppendUint32(b, maxcount)
}
// AppendListXattrRes appends a successful LISTXATTR result: the cookie
// of the last name, the names and the end of list flag.
func AppendListXattrRes(b []byte, cookie uint64, names []string, eof bool) []byte {
b = xdr.AppendUint64(b, cookie)
b = xdr.AppendUint32(b, uint32(len(names)))
for _, n := range names {
b = xdr.AppendString(b, n)
}
return xdr.AppendBool(b, eof)
}
// AppendRemoveXattrArgs appends the REMOVEXATTR argop: the name.
func AppendRemoveXattrArgs(b []byte, name string) []byte {
b = xdr.AppendUint32(b, OpRemovexattr)
return xdr.AppendString(b, name)
}
+425
View File
@@ -0,0 +1,425 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The RPCSEC_GSS server side: the context store keyed by handle, the
// RPCSEC_GSS_INIT and DESTROY procedures, and the credential evaluation
// of COMPOUND calls at the service levels none, integrity and privacy.
package nfs4server
import (
crand "crypto/rand"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
)
// gssMajorStatus values as RFC 2743 section 1.2.2 encodes them: the
// continue needed supplementary bit, and the routine errors shifted
// into bits sixteen and up.
const (
gssMajorOK = 0
gssMajorContinueNeeded = 1
gssMajorDefectiveToken = 9 << 16
gssMajorFailure = 16 << 16
)
// The GSS sequence window the server accepts, RFC 2203 section 5.2.2.
const gssWindow = 32
// A gssSession is one established security context and the sequence
// bookkeeping of its credential. The mutex guards the bookkeeping and
// the token operations together: one context handle presented on two
// connections must not interleave its anti-replay window or its
// sequence counters, RFC 2203 section 5.2.2.
type gssSession struct {
mu sync.Mutex
ctx *krb5.Context
service uint32
lastSeq uint32
seqSet bool
label *rpc.Label
privs []rpc.Privs
}
// gssStore keeps the established contexts by their handle. The handles
// are random, not a counter, so one cannot be guessed and destroyed by
// enumeration.
type gssStore struct {
mu sync.Mutex
byKey map[string]*gssSession
}
func newGSSStore() *gssStore {
return &gssStore{byKey: make(map[string]*gssSession)}
}
func (s *gssStore) put(sess *gssSession) []byte {
handle := make([]byte, 8)
if _, err := crand.Read(handle); err != nil {
panic("nfs4server: the random source failed: " + err.Error())
}
handle[0] = 'G'
handle[1] = 'S'
s.mu.Lock()
defer s.mu.Unlock()
s.byKey[string(handle)] = sess
return handle
}
func (s *gssStore) get(handle []byte) (*gssSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.byKey[string(handle)]
return sess, ok
}
func (s *gssStore) drop(handle []byte) {
s.mu.Lock()
delete(s.byKey, string(handle))
s.mu.Unlock()
}
// gssStore returns the context store, made once per handler. A handler
// without a ServerKey never establishes contexts.
func (h *Handler) gssSessions() *gssStore {
h.mu.Lock()
defer h.mu.Unlock()
if h.gssSt == nil {
h.gssSt = newGSSStore()
}
return h.gssSt
}
// gssInit serves RPCSEC_GSS_INIT: the call data carries the initiator
// context token, which is verified against the service key, stored
// under a fresh handle and answered with the RPCSEC_GSS_INIT result
// holding the handle and the AP-REP.
func (h *Handler) gssInit(token []byte) []byte {
var major, minor uint32
var handle, reply []byte
if h.ServerKey == nil {
major = gssMajorFailure // no key configured: refuse
} else {
ctx, rep, aerr := krb5.AcceptInit(token, h.ServerKey)
if aerr != nil {
major = gssMajorDefectiveToken
minor = 1
} else {
handle = h.gssSessions().put(&gssSession{ctx: ctx})
reply = rep
}
}
return rpc.AppendGSSInitRes(nil, handle, major, minor, gssWindow, reply)
}
// gssDestroy retires the context the credential names. RFC 2203
// section 5.2.3 requires the request to carry a valid verifier under
// the very context it destroys; a request without one, or for a context
// this server never established, is refused.
func (h *Handler) gssDestroy(call rpc.Call) bool {
cred, err := rpc.DecodeGSSCred(call.Cred.Body)
if err != nil || cred.Proc != rpc.GSSProcDestroy {
return false
}
sess, ok := h.gssSessions().get(cred.Handle)
if !ok {
return false
}
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return false
}
if sess.ctx.VerifyMIC(prefix, call.Verifier.Body) != nil {
return false
}
h.gssSessions().drop(cred.Handle)
return true
}
// gssCompound evaluates a COMPOUND call under RPCSEC_GSS: it verifies
// the verifier MIC over the call header, unwraps or checksum-verifies
// the arguments per the service level, runs the compound and protects
// the results the same way. The reply verifier is generated under the
// same session lock as the protected body, so one session's tokens
// leave the server in the order a client verifies them. The last answer
// is false when the RPC layer must answer GARBAGE_ARGS.
func (h *Handler) gssCompound(call rpc.Call, args []byte) ([]byte, rpc.Auth, bool) {
var gcred rpc.GSSCred
if peekU32(call.Cred.Body) == rpc.GSSVersion3 {
// A version three credential carries the version in front.
v3, verr := rpc.DecodeGSSv3Cred(call.Cred.Body)
if verr != nil || v3.Proc != rpc.GSSProcData {
return nil, rpc.Auth{}, false
}
gcred = rpc.GSSCred{Proc: rpc.GSSProcData, Version: rpc.GSSVersion3,
Service: v3.Service, Handle: v3.Handle, Seq: v3.Seq}
} else {
var derr error
gcred, derr = rpc.DecodeGSSCred(call.Cred.Body)
if derr != nil || gcred.Proc != rpc.GSSProcData || gcred.Version != rpc.GSSVersion1 {
return nil, rpc.Auth{}, false
}
}
sess, ok := h.gssSessions().get(gcred.Handle)
if !ok {
return nil, rpc.Auth{}, false
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.seqSet && gcred.Seq != sess.lastSeq+1 && !(gcred.Seq > sess.lastSeq) {
return nil, rpc.Auth{}, false
}
// The verifier is a MIC over the call header with an empty verifier
// field: re-encode that prefix and check the token against it.
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return nil, rpc.Auth{}, false
}
if err := sess.ctx.VerifyMIC(prefix, call.Verifier.Body); err != nil {
return nil, rpc.Auth{}, false
}
sess.lastSeq = gcred.Seq
sess.service = gcred.Service
sess.seqSet = true
var compoundArgs []byte
switch sess.service {
case rpc.SvcPrivacy:
if compoundArgs, err = sess.ctx.Unwrap(args); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
if len(args) < 28 {
return nil, rpc.Auth{}, false
}
compoundArgs = args[:len(args)-28]
if err := sess.ctx.VerifyMIC(compoundArgs, args[len(args)-28:]); err != nil {
return nil, rpc.Auth{}, false
}
default:
compoundArgs = args
}
body, ok := h.compoundCtx(compoundArgs, h.gssCred(sess), nil)
if !ok {
return nil, rpc.Auth{}, false
}
verf := rpc.Auth{}
if sess.service != rpc.SvcNone {
replyPrefix, perr := rpc.AppendAcceptedReply(nil, call.XID, rpc.Auth{}, rpc.AcceptSuccess, rpc.Mismatch{})
if perr != nil {
return nil, rpc.Auth{}, false
}
mic, merr := sess.ctx.GetMIC(replyPrefix)
if merr != nil {
return nil, rpc.Auth{}, false
}
verf = rpc.Auth{Flavor: rpc.FlavorGSS, Body: mic}
}
switch sess.service {
case rpc.SvcPrivacy:
if body, err = sess.ctx.Wrap(body); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
mic, merr := sess.ctx.GetMIC(body)
if merr != nil {
return nil, rpc.Auth{}, false
}
body = append(append([]byte{}, body...), mic...)
}
return body, verf, true
}
// gssCred answers the identity the operations of one context run as.
// Every principal this server authenticates maps to the anonymous
// identity until a mapping table exists, so no principal silently
// becomes root over the export.
func (h *Handler) gssCred(sess *gssSession) cred {
return cred{uid: 65534, gid: 65534}
}
// gssCreate serves RPCSEC_GSS_CREATE over a version three credential:
// the parent context is looked up, the multi-principal assertion is
// verified against the inner handle, labels and privileges are accepted
// into the new child session and the child handle answers the request,
// RFC 7861 section 2.7.1.
func (h *Handler) gssCreate(v3 rpc.GSSv3Cred, headerPrefix []byte, callData []byte) ([]byte, bool) {
parent, ok := h.gssSessions().get(v3.Handle)
if !ok {
return nil, false
}
mpAuth, chanBind, assertions, err := rpc.DecodeCreateArgs(callData)
if err != nil {
return nil, false
}
// Multi-principal authentication rides only over privacy and binds
// the inner handle by its MIC over this call header.
var resMp *rpc.MpAuth
if mpAuth != nil {
if v3.Service != rpc.SvcPrivacy {
return nil, false
}
inner, ok := h.gssSessions().get(mpAuth.InnerHandle)
if !ok {
return nil, false
}
if err := inner.ctx.VerifyMIC(headerPrefix, mpAuth.HeaderMic); err != nil {
return nil, false
}
resMic, merr := inner.ctx.GetMIC(headerPrefix)
if merr != nil {
return nil, false
}
resMp = &rpc.MpAuth{InnerHandle: mpAuth.InnerHandle, HeaderMic: resMic}
}
_ = chanBind // channel binding: asserted, unverified in this build
var granted []rpc.Assertion
child := &gssSession{ctx: parent.ctx, service: v3.Service}
for _, a := range assertions {
switch a.Type {
case rpc.AssertionLabel:
child.label = &a.Label
granted = append(granted, a)
case rpc.AssertionPrivs:
child.privs = append(child.privs, a.Privs)
granted = append(granted, a)
default:
// Unsupported assertions are dropped, not granted.
}
}
childHandle := h.gssSessions().put(child)
return rpc.AppendCreateRes(nil, childHandle, resMp, nil, granted), true
}
// gssList serves RPCSEC_GSS_LIST: the assertion types this server
// grants.
func (h *Handler) gssList() []byte {
return rpc.AppendListRes(nil, []uint32{rpc.AssertionLabel, rpc.AssertionPrivs})
}
// labelOf answers the label assertion of a context, if any.
func (h *Handler) labelOf(handle []byte) *rpc.Label {
sess, ok := h.gssSessions().get(handle)
if !ok {
return nil
}
return sess.label
}
// peekU32 reads the first word of a credential body without consuming
// it: the version three credential starts with the version field while
// the version one form starts with the control procedure.
func peekU32(body []byte) uint32 {
if len(body) < 4 {
return 0
}
return uint32(body[0])<<24 | uint32(body[1])<<16 | uint32(body[2])<<8 | uint32(body[3])
}
// gssv3Control serves the RPCSEC_GSS_CREATE and LIST control messages,
// which ride on NULLPROC under a version three credential protected at
// the integrity or privacy level, RFC 7861 section 5.2. The reply
// verifier is generated under the same session lock as the protected
// result, so the session's tokens leave the server in the order a
// client verifies them.
func (h *Handler) gssv3Control(call rpc.Call, args []byte) ([]byte, rpc.Auth, bool) {
v3, err := rpc.DecodeGSSv3Cred(call.Cred.Body)
if err != nil {
return nil, rpc.Auth{}, false
}
sess, ok := h.gssSessions().get(v3.Handle)
if !ok {
return nil, rpc.Auth{}, false
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.seqSet && v3.Seq != sess.lastSeq+1 && !(v3.Seq > sess.lastSeq) {
return nil, rpc.Auth{}, false
}
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return nil, rpc.Auth{}, false
}
if err := sess.ctx.VerifyMIC(prefix, call.Verifier.Body); err != nil {
return nil, rpc.Auth{}, false
}
sess.lastSeq = v3.Seq
sess.seqSet = true
// The call data carries the control payload protected at the
// session's service level, for every control procedure: a client
// that checksummed or sealed its arguments must see them verified,
// or the shared sequence counters of the context drift apart.
var callData []byte
switch v3.Service {
case rpc.SvcPrivacy:
if callData, err = sess.ctx.Unwrap(args); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
if len(args) < 28 {
return nil, rpc.Auth{}, false
}
callData = args[:len(args)-28]
if err := sess.ctx.VerifyMIC(callData, args[len(args)-28:]); err != nil {
return nil, rpc.Auth{}, false
}
default:
return nil, rpc.Auth{}, false
}
var res []byte
switch v3.Proc {
case rpc.GSSProcCreate:
if ok, cok := h.gssCreate(v3, prefix, callData); !cok {
return nil, rpc.Auth{}, false
} else {
res = ok
}
case rpc.GSSProcList:
res = h.gssList()
default:
return nil, rpc.Auth{}, false
}
replyPrefix, perr := rpc.AppendAcceptedReply(nil, call.XID, rpc.Auth{}, rpc.AcceptSuccess, rpc.Mismatch{})
if perr != nil {
return nil, rpc.Auth{}, false
}
mic, merr := sess.ctx.GetMIC(replyPrefix)
if merr != nil {
return nil, rpc.Auth{}, false
}
verf := rpc.Auth{Flavor: rpc.FlavorGSS, Body: mic}
switch v3.Service {
case rpc.SvcPrivacy:
sealed, serr := sess.ctx.Wrap(res)
if serr != nil {
return nil, rpc.Auth{}, false
}
return sealed, verf, true
case rpc.SvcIntegrity:
mic, merr := sess.ctx.GetMIC(res)
if merr != nil {
return nil, rpc.Auth{}, false
}
return append(append([]byte{}, res...), mic...), verf, true
default:
return nil, rpc.Auth{}, false
}
}
// LabelOf answers the label assertion bound to a context handle, or nil
// when the context carries none.
func (h *Handler) LabelOf(handle []byte) *rpc.Label {
return h.labelOf(handle)
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"fmt"
"net"
"sync"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
)
// cbTimeout bounds one CB call: the client that does not answer in time
// loses the connection's callback service.
const cbTimeout = 5 * time.Second
// cbQueue bounds the callback work one connection may hold in flight.
const cbQueue = 32
// A cbResult is the outcome of one delivered CB_COMPOUND.
type cbResult struct {
res nfs4.CompoundRes
bodies [][]byte
err error
}
// A cbWork is one queued CB_COMPOUND: the operations, the channel the
// synchronous caller waits on, and the action to run once delivery
// ends, whatever its outcome. A nil result channel makes the work fire
// and forget.
type cbWork struct {
tag string
clientid uint64
ops [][]byte
result chan cbResult
done func(cbResult)
}
// A connCB is the per connection callback machinery: one worker
// goroutine delivers every CB_COMPOUND of the connection in order, the
// write side is shared with the request replies, and the pending map
// routes CB replies back to the worker. The worker exists so the
// dispatch path never blocks on a reply only the connection's own read
// loop can route: a CB issued while serving a request of the same
// connection would otherwise wait on itself.
type connCB struct {
conn net.Conn
writeMu *sync.Mutex
cbProgram uint32
mu sync.Mutex
nextXID uint32
pending map[uint32]chan []byte
work chan cbWork
}
func newConnCB(conn net.Conn, writeMu *sync.Mutex) *connCB {
c := &connCB{
conn: conn,
writeMu: writeMu,
pending: make(map[uint32]chan []byte),
work: make(chan cbWork, cbQueue),
}
go c.worker()
return c
}
// worker delivers the queued callbacks one at a time, which keeps the
// back channel slot discipline of one call in flight per slot.
func (c *connCB) worker() {
for w := range c.work {
r := c.deliver(w.tag, w.clientid, w.ops)
if w.done != nil {
w.done(r)
}
if w.result != nil {
w.result <- r
}
}
}
// deliver sends one CB_COMPOUND over the connection and returns the
// decoded result. It runs on the worker goroutine only.
func (c *connCB) deliver(tag string, clientid uint64, ops [][]byte) cbResult {
c.mu.Lock()
xid := c.nextXID
c.nextXID++
ch := make(chan []byte, 1)
c.pending[xid] = ch
c.mu.Unlock()
header := rpc.Call{XID: xid, Program: c.program(), Version: nfs4.Version,
Procedure: nfs4.ProcCompound, Cred: rpc.AuthNull}
req, err := rpc.AppendCall(nil, header)
if err != nil {
c.dropPending(xid)
return cbResult{err: err}
}
args := nfs4.AppendCBCompoundArgs(nil, tag, nfs4.MinorVersion, 0, ops)
if err := c.write(append(req, args...)); err != nil {
c.dropPending(xid)
return cbResult{err: err}
}
var rec []byte
select {
case rec = <-ch:
case <-time.After(cbTimeout):
c.dropPending(xid)
return cbResult{err: fmt.Errorf("cb: no reply in %s", cbTimeout)}
}
reply, err := rpc.DecodeReply(rec)
if err != nil {
return cbResult{err: err}
}
if reply.Rejected || reply.Status != rpc.AcceptSuccess {
return cbResult{err: fmt.Errorf("cb: rpc status %d, rejected %v", reply.Status, reply.Rejected)}
}
r, bodies, err := nfs4.DecodeCompoundResBodiesCB(reply.Body)
return cbResult{res: r, bodies: bodies, err: err}
}
// tryQueue enqueues one work item, or fails when the queue is full.
func (c *connCB) tryQueue(w cbWork) error {
select {
case c.work <- w:
return nil
default:
return fmt.Errorf("cb: the callback queue is full")
}
}
// post queues one fire and forget CB_COMPOUND with an action to run
// once delivery ends.
func (c *connCB) post(tag string, clientid uint64, ops [][]byte, done func(cbResult)) error {
return c.tryQueue(cbWork{tag: tag, clientid: clientid, ops: ops, done: done})
}
// setProgram binds the callback program negotiated by CREATE_SESSION.
func (c *connCB) setProgram(program uint32) {
c.mu.Lock()
c.cbProgram = program
c.mu.Unlock()
}
// program reports the bound callback program.
func (c *connCB) program() uint32 {
c.mu.Lock()
defer c.mu.Unlock()
return c.cbProgram
}
// route delivers a record the demultiplexer identified as a reply to one
// of this connection's CB calls.
func (c *connCB) route(rec []byte) bool {
xid, _, err := rpc.PeekHeader(rec)
if err != nil {
return false
}
c.mu.Lock()
ch, ok := c.pending[xid]
if ok {
delete(c.pending, xid)
}
c.mu.Unlock()
if !ok {
return false
}
ch <- rec
return true
}
func (c *connCB) dropPending(xid uint32) {
c.mu.Lock()
delete(c.pending, xid)
c.mu.Unlock()
}
// wirePool recycles the record marking buffers of outgoing replies. The
// buffer lives only for the write: the connection copies it to the
// kernel, nothing retains a reference, and the cap keeps one oversized
// reply from pinning its memory forever.
var wirePool = sync.Pool{New: func() any { b := make([]byte, 0, 16<<10); return b }}
// maxWireBuf bounds the buffers the pool holds on to.
const maxWireBuf = maxRecord + 16
func (c *connCB) write(data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
buf := wirePool.Get().([]byte)[:0]
buf = rpc.AppendRecord(buf, data)
_, err := c.conn.Write(buf)
if cap(buf) <= maxWireBuf {
wirePool.Put(buf)
}
return err
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Directory delegations: the store of granted directory delegations, the
// GET_DIR_DELEGATION handler and the notification the server pushes to a
// deleg holder when the directory changes under it.
package nfs4server
import (
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// A dirDeleg is one granted directory delegation.
type dirDeleg struct {
stateid nfs4.Stateid
sessID nfs4.SessionID
clientid uint64
fh nfsfs.Handle
types nfs4.Bitmap
}
// dirDelegStore tracks the live directory delegations. A directory
// carries at most one.
type dirDelegStore struct {
mu sync.Mutex
next uint64
byDir map[string]*dirDeleg
}
func newDirDelegStore() *dirDelegStore {
return &dirDelegStore{byDir: make(map[string]*dirDeleg)}
}
// grant issues the directory delegation; a second client asking for the
// same directory is denied, the same client receives its live stateid.
func (s *dirDelegStore) grant(sessID nfs4.SessionID, clientid uint64, fh nfsfs.Handle) (*dirDeleg, uint32) {
s.mu.Lock()
defer s.mu.Unlock()
if d, ok := s.byDir[fileKey(fh)]; ok {
if d.clientid == clientid {
return d, nfs4.ErrOK
}
return nil, nfs4.ErrDenied
}
s.next++
var st nfs4.Stateid
setStateidSeq(&st, 1)
copy(st[4:], "DDEL")
for i := range 8 {
st[15-i] = byte(s.next >> (8 * i))
}
d := &dirDeleg{stateid: st, sessID: sessID, clientid: clientid, fh: fh}
s.byDir[fileKey(fh)] = d
return d, nfs4.ErrOK
}
// dropClient and dropSession remove the delegations that die with the
// client or the session.
func (s *dirDelegStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byDir {
if d.clientid == clientid {
delete(s.byDir, key)
}
}
}
func (s *dirDelegStore) dropSession(sessID nfs4.SessionID) {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byDir {
if d.sessID == sessID {
delete(s.byDir, key)
}
}
}
// count reports how many directory delegations are live.
func (s *dirDelegStore) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.byDir)
}
// dirDelegs returns the directory delegation store, made once per
// handler.
func (h *Handler) dirDelegs() *dirDelegStore {
h.mu.Lock()
defer h.mu.Unlock()
if h.dirDelegSt == nil {
h.dirDelegSt = newDirDelegStore()
}
return h.dirDelegSt
}
// getDirDelegationOp serves GET_DIR_DELEGATION over the current
// directory: the stateid hangs from an open of the directory and the
// notification types are echoed back, this server can produce them all.
func (h *Handler) getDirDelegationOp(d *xdr.Decoder, reg *fhreg, sessID nfs4.SessionID, clientid uint64) ([]byte, uint32, error) {
signalAvail, err := d.Bool()
if err != nil {
return nil, 0, err
}
notifyTypes, err := nfs4.ReadBitmap(d)
if err != nil {
return nil, 0, err
}
for range 2 {
if _, err = d.Int64(); err != nil { // attr delay seconds
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // attr delay nseconds
return nil, 0, err
}
}
if _, err = nfs4.ReadBitmap(d); err != nil { // child attributes
return nil, 0, err
}
if _, err = nfs4.ReadBitmap(d); err != nil { // dir attributes
return nil, 0, err
}
_ = signalAvail
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
// Only directories carry directory delegations.
if info, gerr := h.FS.Getattr(reg.cur); gerr == nil && !info.IsDir() {
return nil, nfs4.ErrWrongType, nil
}
dg, status := h.dirDelegs().grant(sessID, clientid, reg.cur)
if status != nfs4.ErrOK {
return nil, status, nil
}
verf := h.writeVerifier()
return nfs4.AppendGetDirDelegationResOK(nil, verf, dg.stateid, notifyTypes, nfs4.Bitmap{}, nfs4.Bitmap{}), nfs4.ErrOK, nil
}
// notifyDir pushes one CB_NOTIFY to the holder of the directory
// delegation, if the changed directory is delegated to somebody else.
// The notification is advisory and queued onto the holder's callback
// worker, so a mutation served on the holder's own connection never
// waits for the reply only that connection's read loop can route.
func (h *Handler) notifyDir(dir nfsfs.Handle, change nfs4.Notify4) {
dd, ok := h.dirDelegByFH(dir)
if !ok {
return
}
args := nfs4.AppendCBNotifyArgs(nil, dd.stateid, dir, []nfs4.Notify4{change})
_ = h.queueCB(dd.sessID, "dir-notify", [][]byte{args})
}
// dirDelegByFH finds the live delegation of a directory.
func (h *Handler) dirDelegByFH(dir nfsfs.Handle) (*dirDeleg, bool) {
h.mu.Lock()
store := h.dirDelegSt
h.mu.Unlock()
if store == nil {
return nil, false
}
store.mu.Lock()
defer store.mu.Unlock()
d, ok := store.byDir[fileKey(dir)]
return d, ok
}
// notifyDirOf is the helper the mutating operations call: it reads the
// parent through the backend and pushes the change. nameCookie carries
// the cookie semantics of the event; zero suffices for the wire shape
// this build answers with.
func (h *Handler) notifyDirOf(dir nfsfs.Handle, mask nfs4.Bitmap, name string) {
vals := buildNotifyBody(mask, name)
if vals == nil {
return
}
h.notifyDir(dir, nfs4.Notify4{Mask: mask, Vals: vals})
}
// buildNotifyBody encodes the change body the mask names.
func buildNotifyBody(mask nfs4.Bitmap, name string) []byte {
if mask.Has(nfs4.NotifyAddEntry) {
return nfs4.AppendNotifyAdd(nil, name, 0, true)
}
if mask.Has(nfs4.NotifyRemoveEntry) {
return nfs4.AppendNotifyRemove(nil, name, 0)
}
return nil
}
// notifyAppendCB walks the decoded notify body for the tests: it splits
// the name out of a notify_entry4 payload.
func notifyNameOf(body []byte) string {
d := xdr.NewDecoder(body)
if _, err := d.Uint32(); err != nil { // nad_old_entry count
return ""
}
name, err := d.String()
if err != nil {
return ""
}
return name
}
+276
View File
@@ -0,0 +1,276 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"net"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// waitFor polls a condition until it holds or the deadline passes; the
// callback notifications travel on the receiver's callback worker, so
// they arrive a moment after the operation that triggered them.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("timed out waiting for " + what)
}
// startCBServer listens on the loopback and serves the handler; the
// returned shutdown closes the listener.
func startCBServer(t *testing.T, h *Handler) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
t.Cleanup(func() { ln.Close() })
return ln.Addr().String()
}
func TestDirDelegationNotify(t *testing.T) {
h := testTree(t)
addr := startCBServer(t, h)
// Client A takes the directory delegation, its back channel is where
// the change notifications travel.
ca, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer ca.Close()
if err := ca.Establish("dd-a"); err != nil {
t.Fatalf("establish a: %v", err)
}
sidA, _ := ca.SessionID()
// Client B will mutate the delegated directory.
cb, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer cb.Close()
if err := cb.Establish("dd-b"); err != nil {
t.Fatalf("establish b: %v", err)
}
sidB, _ := cb.SessionID()
body := mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "gdd", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetDirDelegationArgs(nil, false,
nfs4.OfBits(nfs4.NotifyAddEntry, nfs4.NotifyRemoveEntry),
nfs4.NfsTime{}, nfs4.NfsTime{}, nfs4.Bitmap{}, nfs4.Bitmap{}),
}))
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("get dir delegation: status %d, %v", res.Status, err)
}
gd := xdr.NewDecoder(bodies[2])
if st, gerr := gd.Uint32(); gerr != nil || st != nfs4.GddOK {
t.Fatalf("gdd status %d: %v", st, gerr)
}
if _, gerr := gd.Raw(8); gerr != nil { // cookie verifier
t.Fatal(gerr)
}
var ddSt nfs4.Stateid
raw, rerr := gd.Raw(16)
if rerr != nil {
t.Fatal(rerr)
}
copy(ddSt[:], raw)
if !bytes.HasPrefix(ddSt[4:], []byte("DDEL")) {
t.Fatalf("stateid other % x", ddSt[4:])
}
if h.dirDelegs().count() != 1 {
t.Fatalf("dir delegs %d, want 1", h.dirDelegs().count())
}
// A second client asking for the same directory is denied.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "gdd2", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetDirDelegationArgs(nil, false,
nfs4.OfBits(nfs4.NotifyAddEntry), nfs4.NfsTime{}, nfs4.NfsTime{},
nfs4.Bitmap{}, nfs4.Bitmap{}),
}))
res, _, err = nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatal(err)
}
if res.Status != nfs4.ErrDenied {
t.Fatalf("second grant: status %d, want DENIED", res.Status)
}
// B creates an entry: the ADD notification travels to A.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "mk", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, 2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
// Regular files ride OPEN; CREATE makes everything else, so the
// notified entry is a directory.
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "notified.d", "", 0, 0,
0o755),
}))
if res, _, _ = nfs4.DecodeCompoundResBodies(body); res.Status != nfs4.ErrOK {
t.Fatalf("create: status %d", res.Status)
}
var notes []nfs4.CBNotify
waitFor(t, "the change notification to reach client A", func() bool {
notes = ca.Notified()
return len(notes) == 1
})
if !bytes.Equal(notes[0].Stateid[:], ddSt[:]) {
t.Fatal("notification carries another stateid")
}
if len(notes[0].Changes) != 1 {
t.Fatalf("changes %d, want 1", len(notes[0].Changes))
}
change := notes[0].Changes[0]
if !change.Mask.Has(nfs4.NotifyAddEntry) {
t.Fatalf("mask %v", change.Mask)
}
if name := notifyNameOf(change.Vals); name != "notified.d" {
t.Fatalf("notified name %q", name)
}
// DESTROY_CLIENTID of A drops the delegation.
h.dirDelegs().dropClient(sidA.ClientIDOf())
if h.dirDelegs().count() != 0 {
t.Fatalf("dir delegs after drop %d, want 0", h.dirDelegs().count())
}
}
func TestCBNotifyLock(t *testing.T) {
h := testTree(t)
addr := startCBServer(t, h)
ca, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer ca.Close()
if err := ca.Establish("lock-a"); err != nil {
t.Fatalf("establish a: %v", err)
}
sidA, _ := ca.SessionID()
cb, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer cb.Close()
if err := cb.Establish("lock-b"); err != nil {
t.Fatalf("establish b: %v", err)
}
sidB, _ := cb.SessionID()
// A opens a.txt and locks the first bytes of it; the lock hangs from
// the open the way RFC 8881 section 18.10 requires.
body := mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sidA.ClientIDOf(), []byte("a-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
}))
res, openBodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open a: status %d, %v", res.Status, err)
}
var openSt nfs4.Stateid
copy(openSt[:], openBodies[2])
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "lock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 2, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openSt, sidA.ClientIDOf(), []byte("a-owner"),
nfs4.LockTypeWrite, false, 0, 5),
}))
res, lockBodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("lock a: status %d, %v", res.Status, err)
}
var lockSt nfs4.Stateid
copy(lockSt[:], lockBodies[3])
// B's conflicting lock is denied and recorded as a waiter. B locks
// through its own open: one client's open stateid never authorises
// another client's lock. A's write open carries a write delegation,
// so B's first open answers DELAY while the recall runs and the
// retry proceeds.
var openStB nfs4.Stateid
seqB := uint32(0)
for {
seqB++
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "open-b", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, seqB, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sidB.ClientIDOf(), []byte("b-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
}))
var bBodies [][]byte
res, bBodies, err = nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatalf("open b: %v", err)
}
if res.Status == nfs4.ErrOK {
copy(openStB[:], bBodies[2])
break
}
if res.Status != nfs4.ErrDelay {
t.Fatalf("open b: status %d", res.Status)
}
time.Sleep(time.Millisecond)
}
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "lock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidB, seqB+1, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openStB, sidB.ClientIDOf(), []byte("b-owner"),
nfs4.LockTypeWrite, false, 0, 5),
}))
res, _, err = nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrDenied {
t.Fatalf("lock b: status %d, want DENIED", res.Status)
}
// A releases: the CB_NOTIFY_LOCK travels to B's back channel on B's
// callback worker, so it arrives a moment after the unlock answers.
body = mustCompound(t, h, nfs4.AppendCompoundArgs(nil, "unlock", nfs4.MinorVersion, [][]byte{
nfs4.AppendSequenceArgs(nil, sidA, 3, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockuArgs(nil, lockSt, 0, 5),
}))
_ = body
if res, _, _ = nfs4.DecodeCompoundResBodies(body); res.Status != nfs4.ErrOK {
t.Fatalf("unlock: status %d", res.Status)
}
var locks []nfs4.CBNotifyLock
waitFor(t, "the lock notification to reach client B", func() bool {
locks = cb.NotifiedLocks()
return len(locks) == 1
})
if locks[0].Clientid == 0 || string(locks[0].Owner) != "b-owner" {
t.Fatalf("notified owner %d/%q", locks[0].Clientid, locks[0].Owner)
}
if len(ca.NotifiedLocks()) != 0 {
t.Fatal("the holder was notified instead of the waiter")
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"sync"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
)
// A grace is the reclaim window of one handler: it opens when the handler
// starts and closes after the configured period. The server recovers no
// state across restarts, so a fresh window means every reclaim attempt
// inside it is answered, and every attempt after it is NFS4ERR_NO_GRACE.
type grace struct {
mu sync.Mutex
end time.Time
done map[uint64]bool
}
func newGrace(period time.Duration) *grace {
return &grace{end: time.Now().Add(period), done: make(map[uint64]bool)}
}
// active reports whether the reclaim window still runs.
func (g *grace) active(now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
return now.Before(g.end)
}
// reclaimOKFor reports whether the client may reclaim: inside the
// window and without an earlier RECLAIM_COMPLETE. The status names the
// refusal: NO_GRACE for a closed window, COMPLETE_ALREADY for a client
// that announced it was done.
func (g *grace) reclaimOKFor(clientid uint64, now time.Time) (bool, uint32) {
g.mu.Lock()
defer g.mu.Unlock()
if !now.Before(g.end) {
return false, nfs4.ErrNoGrace
}
if g.done[clientid] {
return false, nfs4.ErrCompleteAlready
}
return true, 0
}
// complete records the client's RECLAIM_COMPLETE and reports whether this
// was the first announcement.
func (g *grace) complete(clientid uint64, now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.done[clientid] {
return false
}
g.done[clientid] = true
return true
}
+353
View File
@@ -0,0 +1,353 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// The permission gate: the identity a call carries decides what the
// data operations may touch. Root passes everything; an identity that
// holds no rights on the object is refused before the backend runs.
func TestPermissionGate(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "secret.txt"), []byte("s"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "public.txt"), []byte("p"), 0o644); err != nil {
t.Fatal(err)
}
h := testTree(t)
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
h.FS = backend
sid, seq := newSession(t, h)
other := cred{uid: 65534, gid: 65534}
run := func(c cred, s uint32, ops [][]byte) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "perm", nfs4.MinorVersion, all), c)
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
// Another identity cannot read a 0600 file, but the public one it
// can; the ACCESS operation agrees with both answers.
if res := run(other, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "secret.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrAccess {
t.Fatalf("read of a private file as another identity: %d, want ACCESS", res.Status)
}
seq++
if res := run(other, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "public.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrOK {
t.Fatalf("read of a public file as another identity: %d", res.Status)
}
seq++
// Root passes the gate.
if res := run(cred{uid: 0, gid: 0}, seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "secret.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 8),
}); res.Status != nfs4.ErrOK {
t.Fatalf("read of a private file as root: %d", res.Status)
}
}
// The wire bounds: a CLONE or WRITE_SAME whose sizes overflow or exceed
// the limits is refused with INVAL, never used to size an allocation.
func TestCloneAndWriteSameBounds(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "bounds", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
// A CLONE whose source offset and count wrap the size guard. The
// saved handle is the source, the current one the destination.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "sub"),
nfs4.AppendLookup(nil, "b.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendLookupp(nil),
nfs4.AppendLookupp(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendCloneArgs(nil, nfs4.Stateid{}, nfs4.Stateid{},
1<<63, 0, (1<<63)+8),
}); res.Status != nfs4.ErrInval {
t.Fatalf("wrapping clone: %d, want INVAL", res.Status)
}
seq++
// A WRITE_SAME with a block size beyond the write limit.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendWriteSameArgs(nil, nfs4.Stateid{}, nfs4.StableFileSync,
0, 1<<50, 1, 0, 0, 0, []byte("x")),
}); res.Status != nfs4.ErrInval {
t.Fatalf("oversized write same: %d, want INVAL", res.Status)
}
seq++
// A READ past the signed offset range is refused the same way.
if res := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 1<<63, 4),
}); res.Status != nfs4.ErrInval {
t.Fatalf("read at an impossible offset: %d, want INVAL", res.Status)
}
}
// The CURRENT_STATEID: after an OPEN in the same session, the special
// form names the caller's own open of the file.
func TestCurrentStateid(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
current := nfs4.Stateid{0, 0, 0, 1}
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid.ClientIDOf(), []byte("cur"),
nfs4.ShareAccessBoth, 0, true, 0o644, "cur.txt"),
nfs4.AppendWriteArgs(nil, current, 0, nfs4.StableFileSync, []byte("data")))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "cur", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write through the current stateid: status %d, %v", res.Status, err)
}
}
// A data operation may travel on the delegation stateid of the file,
// RFC 8881 section 10.3: the Linux client reads through the delegation
// it was granted. The delegation stateid belongs to its holder; a
// foreign client presenting it is refused.
func TestReadThroughDelegationStateid(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid.ClientIDOf(), []byte("deleg"),
nfs4.ShareAccessRead, 0, false, 0, "a.txt"),
nfs4.AppendGetfh(nil))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "deleg", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, bodies, err := nfs4.DecodeCompoundResBodies(body)
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
_, delegType, delegSt, err := nfs4.DecodeOpenResDeleg(bodies[2])
if err != nil || delegType != nfs4.OpenDelegRead {
t.Fatalf("delegation %d: %v", delegType, err)
}
// The holder reads through the delegation stateid.
fh, err := xdr.NewDecoder(bodies[3]).VarOpaque()
if err != nil {
t.Fatal(err)
}
seq++
read := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutfh(nil, fh),
nfs4.AppendReadArgs(nil, delegSt, 0, 64))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "read", nfs4.MinorVersion, read), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err = nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("read through the delegation stateid: status %d, %v", res.Status, err)
}
// A foreign session presenting the same stateid is refused.
sid2, seq2 := newSession(t, h)
foreign := append([][]byte{nfs4.AppendSequenceArgs(nil, sid2, seq2, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadArgs(nil, delegSt, 0, 64))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "foreign", nfs4.MinorVersion, foreign), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err = nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrBadStateid {
t.Fatalf("foreign read through the delegation: status %d, %v", res.Status, err)
}
}
// EXCLUSIVE4_1 creates like its guarded equivalent, with the replay
// semantics of the exclusive forms: a retry with the same verifier
// replays into success, a create over an existing name with a
// different verifier answers EXIST, RFC 8881 section 18.16.
func TestOpenExclusive41(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, verf byte, name string) nfs4.CompoundRes {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgsExclusive41(nil, sid.ClientIDOf(), []byte("ex"),
[8]byte{verf, 2, 3, 4, 5, 6, 7, 8}, 0o644, name))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "ex41", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return res
}
if res := run(seq, 1, "ex41.txt"); res.Status != nfs4.ErrOK {
t.Fatalf("exclusive 4.1 create: %d", res.Status)
}
// The same verifier replays the lost reply into success.
if res := run(seq+1, 1, "ex41.txt"); res.Status != nfs4.ErrOK {
t.Fatalf("exclusive 4.1 replay: %d, want OK", res.Status)
}
// A different verifier over the existing name is EXIST.
if res := run(seq+2, 2, "ex41.txt"); res.Status != nfs4.ErrExist {
t.Fatalf("exclusive 4.1 over an existing name: %d, want EXIST", res.Status)
}
}
// A lease that lapsed while the client was gone frees its state and
// ends its identity: the next SEQUENCE answers EXPIRED and the client
// establishes a new one, while nothing of the old life denies anybody.
func TestLeaseExpiryReleasesState(t *testing.T) {
h := testTree(t)
h.LeasePeriod = 20 * time.Millisecond
sid, seq := newSession(t, h)
clientid := sid.ClientIDOf()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, clientid, []byte("lease"),
nfs4.ShareAccessBoth, nfs4.ShareDenyBoth, true, 0o644, "lease.txt"))
body, ok := h.compound(nfs4.AppendCompoundArgs(nil, "lease", nfs4.MinorVersion, all), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
time.Sleep(60 * time.Millisecond)
expired := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq+1, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "after", nfs4.MinorVersion, expired), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrExpired {
t.Fatalf("sequence after the lease lapsed: status %d, %v", res.Status, err)
}
// A second client takes the name the expired client held open with
// deny bits: nothing of the old life denies it anymore.
sid2, seq2 := newSession(t, h)
all2 := append([][]byte{nfs4.AppendSequenceArgs(nil, sid2, seq2, 0, defaultSlots-1, true)},
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, sid2.ClientIDOf(), []byte("fresh"),
nfs4.ShareAccessBoth, 0, true, 0o644, "lease.txt"))
body, ok = h.compound(nfs4.AppendCompoundArgs(nil, "fresh", nfs4.MinorVersion, all2), cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
if res, _, err := nfs4.DecodeCompoundResBodies(body); err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open after the expired client: status %d, %v", res.Status, err)
}
}
// The seek answers the virtual hole at the end of a dense file with the
// size and the eof flag, RFC 7862 section 15.11.
func TestSeekVirtualHoleOverWire(t *testing.T) {
h := testTree(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
defer ln.Close()
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.Establish("seek"); err != nil {
t.Fatal(err)
}
res, bodies, err := cl.Compound("seek", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, nfs4.Stateid{}, 0, nfs4.ContentHole),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("seek hole in a dense file: status %d, %v", res.Status, err)
}
d := xdr.NewDecoder(bodies[2])
eof, err := d.Bool()
if err != nil {
t.Fatal(err)
}
offset, err := d.Uint64()
if err != nil {
t.Fatal(err)
}
if !eof || offset != 9 {
t.Fatalf("seek hole: eof %v offset %d, want the size 9 with eof", eof, offset)
}
// A seek past the end is NXIO.
res, _, err = cl.Compound("seek", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, nfs4.Stateid{}, 1<<40, nfs4.ContentData),
})
if err != nil || res.Status != nfs4.ErrNXIO {
t.Fatalf("seek past the end: status %d, want NXIO, %v", res.Status, err)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"net"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
)
// The three RPCSEC_GSS service levels round trip against the real TCP
// client: the credential sequence window, the verifier MIC over the
// call header, the checksummed arguments and results at integrity and
// the sealed ones at privacy.
func TestKerberosServiceLevels(t *testing.T) {
h := testTree(t)
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
h.ServerKey = key
h.ServiceName = "nfs"
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
defer ln.Close()
cases := []struct {
name string
svc uint32
}{
{"krb5", rpc.SvcNone},
{"krb5i", rpc.SvcIntegrity},
{"krb5p", rpc.SvcPrivacy},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
// The session exists before the GSS switch: the COMPOUND
// then carries SEQUENCE under the GSS credential.
if err := cl.Establish("gss-" + tc.name); err != nil {
t.Fatalf("establish: %v", err)
}
if err := cl.EnableGSS(krb5.EtypeAES256, key, "EXAMPLE.ORG", "nfs",
"petr@EXAMPLE.ORG", tc.svc); err != nil {
t.Fatalf("enable gss: %v", err)
}
res, bodies, err := cl.Compound("gss", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize)),
})
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(bodies) != 2 {
t.Fatalf("compound: status %d bodies %d", res.Status, len(bodies))
}
// A second call walks the sequence window one further.
res, _, err = cl.Compound("gss2", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("second compound: status %d %v", res.Status, err)
}
if err := cl.DisableGSS(); err != nil {
t.Fatalf("disable: %v", err)
}
// After the destroy the client falls back to AUTH_SYS and the
// compound succeeds anonymously again.
res2, _, err2 := cl.Compound("after", [][]byte{nfs4.AppendPutRootfh(nil)})
if err2 != nil || res2.Status != nfs4.ErrOK {
t.Fatalf("compound after disable: %d %v", res2.Status, err2)
}
})
}
}
// RPCSEC_GSSv3: the CREATE control procedure binds assertions to a
// child handle and the compounds under the child carry the version
// three credential, RFC 7861.
func TestGSSv3CreateAndUse(t *testing.T) {
h := testTree(t)
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 9)
}
h.ServerKey = key
h.ServiceName = "nfs"
addr := startCBServer(t, h)
cl, err := nfsclient.Dial(addr)
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.Establish("v3"); err != nil {
t.Fatalf("establish: %v", err)
}
if err := cl.EnableGSS(krb5.EtypeAES256, key, "EXAMPLE.ORG", "nfs",
"petr@EXAMPLE.ORG", rpc.SvcIntegrity); err != nil {
t.Fatalf("enable gss: %v", err)
}
// CREATE with a label assertion over the parent context.
child, err := cl.CreateGSSChild([]rpc.Assertion{{
Type: rpc.AssertionLabel,
Label: rpc.Label{
LfsId: 1,
PiId: 0,
Bytes: []byte("secret"),
},
}})
if err != nil {
t.Fatalf("create: %v", err)
}
if len(child) == 0 {
t.Fatal("no child handle")
}
// A compound under the child handle rides the version three
// credential at the integrity level.
res, _, err := cl.Compound("v3", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("compound under child: status %d %v", res.Status, err)
}
// The server bound the label to the child context.
if lbl := h.labelOf(child); lbl == nil || string(lbl.Bytes) != "secret" {
t.Fatalf("label not bound: %+v", lbl)
}
// LIST answers the supported assertion types.
types, err := cl.ListGSSAssertions()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(types) != 2 {
t.Fatalf("list types %v", types)
}
}
+537
View File
@@ -0,0 +1,537 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"net"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// layoutDeviceID names the one storage device behind every layout: the
// metadata server of this build is also its data server, so one identity
// serves both roles.
var layoutDeviceID = newDeviceID()
// newDeviceID builds the fixed device identity of this server.
func newDeviceID() [16]byte {
var id [16]byte
copy(id[:], "pnfs42mdsds00001")
return id
}
// A layout is one granted pNFS layout: the session and client it belongs
// to, the file it covers, the byte range and the stateid the client
// addresses it by.
type layout struct {
stateid nfs4.Stateid
sessID nfs4.SessionID
clientid uint64
fh nfsfs.Handle
offset uint64
length uint64
}
// layoutServer tracks the layouts the metadata server has granted. The
// store lives in memory: a server restart drops every layout and the
// clients re-request them through the grace window of the restart.
type layoutServer struct {
mu sync.Mutex
next uint64
layouts map[string]*layout // by layout stateid other
}
func newLayoutServer() *layoutServer {
return &layoutServer{next: randCounter(), layouts: make(map[string]*layout)}
}
// grant issues a layout over the requested range of the file handle,
// replacing the layout the same client already holds on the same file,
// so a re-request or a retry never piles entries up. The stateid other
// carries the LAYOUT mark and a counter; the sequence field starts at
// one, as RFC 8881 section 12.5.2 has it for layout stateids.
func (l *layoutServer) grant(sessID nfs4.SessionID, clientid uint64, fh nfsfs.Handle, offset, length uint64) *layout {
l.mu.Lock()
defer l.mu.Unlock()
key := fileKey(fh)
for _, lay := range l.layouts {
if lay.clientid == clientid && fileKey(lay.fh) == key {
lay.offset, lay.length, lay.sessID = offset, length, sessID
return lay
}
}
l.next++
var st nfs4.Stateid
setStateidSeq(&st, 1)
copy(st[4:], "LAYOUT")
for i := range 6 {
st[15-i] = byte(l.next >> (8 * i))
}
lay := &layout{stateid: st, sessID: sessID, clientid: clientid, fh: fh, offset: offset, length: length}
l.layouts[string(st[4:])] = lay
return lay
}
// lookup finds a live layout by the stateid the client presents.
func (l *layoutServer) lookup(st nfs4.Stateid, fh nfsfs.Handle) (*layout, uint32) {
l.mu.Lock()
defer l.mu.Unlock()
lay, ok := l.layouts[string(st[4:])]
if !ok || fileKey(lay.fh) != fileKey(fh) {
return nil, nfs4.ErrBadStateid
}
return lay, nfs4.ErrOK
}
// drop removes the layout identified by the stateid and answers the status
// of the removal.
func (l *layoutServer) drop(st nfs4.Stateid) uint32 {
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.layouts[string(st[4:])]; !ok {
return nfs4.ErrBadStateid
}
delete(l.layouts, string(st[4:]))
return nfs4.ErrOK
}
// dropSession drops every layout of one session, which DESTROY_SESSION
// requires: layouts are session bound state.
func (l *layoutServer) dropSession(sessID nfs4.SessionID) {
l.mu.Lock()
defer l.mu.Unlock()
for other, lay := range l.layouts {
if lay.sessID == sessID {
delete(l.layouts, other)
}
}
}
// dropClient drops every layout of one client, which DESTROY_CLIENTID
// requires.
func (l *layoutServer) dropClient(clientid uint64) {
l.mu.Lock()
defer l.mu.Unlock()
for other, lay := range l.layouts {
if lay.clientid == clientid {
delete(l.layouts, other)
}
}
}
// count reports how many layouts the server has granted.
func (l *layoutServer) count() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.layouts)
}
// layoutGetOp serves LAYOUTGET: it validates the open stateid the client
// presents, grants a flexfiles layout over the requested range and names
// this server as the data server the client reads and writes through.
func (h *Handler) layoutGetOp(d *xdr.Decoder, reg *fhreg, sessID nfs4.SessionID, clientid uint64) ([]byte, uint32, error) {
if _, err := d.Bool(); err != nil { // signal_avail
return nil, 0, err
}
layoutType, err := d.Uint32()
if err != nil {
return nil, 0, err
}
iomode, err := d.Uint32()
if err != nil {
return nil, 0, err
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
length, err := d.Uint64()
if err != nil {
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // minlength
return nil, 0, err
}
var openSt nfs4.Stateid
raw, rerr := d.Raw(16)
if rerr != nil {
return nil, 0, rerr
}
copy(openSt[:], raw)
maxcount, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if layoutType != nfs4.LayoutTypeFlexfiles && layoutType != nfs4.LayoutTypeFlexFilesV2 &&
layoutType != nfs4.LayoutTypeFiles && layoutType != nfs4.LayoutTypeBlock &&
layoutType != nfs4.LayoutTypeObjects && layoutType != nfs4.LayoutTypeScsi {
return nil, nfs4.ErrUnknownLayoutType, nil
}
if iomode == 0 || iomode > nfs4.IoModeRW {
return nil, nfs4.ErrBadIOMode, nil
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
// A layout hangs from a real open of the caller: the anonymous
// stateid forms never qualify.
if _, status := h.openStates().lookupOpen(openSt, reg.cur, clientid); status != nfs4.ErrOK {
return nil, status, nil
}
// The body is built through one closure so the maxcount check can
// run against a scratch stateid before anything is granted: a
// request whose answer cannot fit leaves no layout behind, RFC 8881
// section 18.43.
build := func(st nfs4.Stateid) []byte {
switch layoutType {
case nfs4.LayoutTypeFiles:
return nfs4.AppendFileLayoutBody(nil, layoutDeviceID,
0 /* util: no striping */, 0, 0, [][]byte{reg.cur})
case nfs4.LayoutTypeBlock:
return nfs4.AppendBlockDeviceAddr(nil, nfs4.BlockVolume{
DeviceID: layoutDeviceID,
BaseOffset: offset,
BlockCount: length,
})
case nfs4.LayoutTypeObjects:
return nfs4.AppendObjectLayoutBody(nil, layoutDeviceID, nfs4.ObjectLayout{
NumComponents: 1, StripeUnit: 4096, GroupWidth: 1, GroupDepth: 1,
})
case nfs4.LayoutTypeScsi:
return nfs4.AppendScsiLayoutBody(nil, layoutDeviceID, offset, length, offset)
case nfs4.LayoutTypeFlexFilesV2:
// The version two body of draft-haynes-nfsv4-flex-filesv2-00:
// one stateid per version and the AUTH_NONE credential, which
// the draft prescribes for tight coupling over synthetic
// identities.
return nfs4.AppendFlexFileLayoutBodyV2(nil, 0, 0, []nfs4.FlexMirrorV2{{
DataServers: []nfs4.FlexDataServerV2{{
DeviceID: layoutDeviceID,
Stateids: []nfs4.Stateid{st},
FHs: [][]byte{reg.cur},
AuthFlavor: 0, // AUTH_NONE
}},
}})
default:
return nfs4.AppendFlexFileLayoutBody(nil, 0, 0, []nfs4.FlexMirror{{
DataServers: []nfs4.FlexDataServer{{
DeviceID: layoutDeviceID,
Stateid: st,
FHs: [][]byte{reg.cur},
}},
}})
}
}
appendRes := func(st nfs4.Stateid) []byte {
return nfs4.AppendLayoutGetRes(nil, st, false, []nfs4.Layout4{{
Offset: offset,
Length: length,
IoMode: iomode,
Type: layoutType,
Body: build(st),
}})
}
if maxcount != 0 && uint32(len(appendRes(nfs4.Stateid{}))) > maxcount {
return nil, nfs4.ErrTooSmall, nil
}
lay := h.layouts().grant(sessID, clientid, reg.cur, offset, length)
return appendRes(lay.stateid), nfs4.ErrOK, nil
}
// layoutCommitOp serves LAYOUTCOMMIT: the layout must still be live and
// the client's last write offset may grow the file. The answer names the
// size the file is left at.
func (h *Handler) layoutCommitOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
length, err := d.Uint64()
if err != nil {
return nil, 0, err
}
if _, err = d.Bool(); err != nil { // reclaim
return nil, 0, err
}
var st nfs4.Stateid
raw, rerr := d.Raw(16)
if rerr != nil {
return nil, 0, rerr
}
copy(st[:], raw)
lastWriteSet, err := d.Bool()
if err != nil {
return nil, 0, err
}
var lastWrite uint64
if lastWriteSet {
if lastWrite, err = d.Uint64(); err != nil {
return nil, 0, err
}
}
timeSet, err := d.Bool()
if err != nil {
return nil, 0, err
}
if timeSet {
if _, err = d.Int64(); err != nil {
return nil, 0, err
}
if _, err = d.Uint32(); err != nil {
return nil, 0, err
}
}
if _, err = d.Uint32(); err != nil { // layout update type
return nil, 0, err
}
if _, err = d.VarOpaque(); err != nil { // layout update body
return nil, 0, err
}
_ = offset
_ = length
if _, status := h.layouts().lookup(st, reg.cur); status != nfs4.ErrOK {
return nil, status, nil
}
info, ferr := h.FS.Getattr(reg.cur)
if ferr != nil {
return nil, mapErr(ferr), nil
}
newSize := uint64(info.Size)
if lastWriteSet && lastWrite+1 > newSize {
w := h.writer()
if w == nil {
return nil, nfs4.ErrROFS, nil
}
grown := int64(lastWrite + 1)
if err := w.Setattr(reg.cur, nfsfs.SetAttrs{Size: &grown}); err != nil {
return nil, mapErr(err), nil
}
newSize = lastWrite + 1
}
return nfs4.AppendLayoutCommitRes(nil, newSize), nfs4.ErrOK, nil
}
// layoutReturnOp serves LAYOUTRETURN: the client gives the layout back.
// A file return drops the named layout, a whole client or file system
// return drops every layout of the session.
func (h *Handler) layoutReturnOp(d *xdr.Decoder, reg *fhreg, sessID nfs4.SessionID, clientid uint64) ([]byte, uint32, error) {
reclaim, err := d.Bool()
if err != nil {
return nil, 0, err
}
layoutType, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // iomode
return nil, 0, err
}
kind, err := d.Uint32()
if err != nil {
return nil, 0, err
}
_ = reclaim
if layoutType != nfs4.LayoutTypeFlexfiles && layoutType != nfs4.LayoutTypeFlexFilesV2 {
return nil, nfs4.ErrUnknownLayoutType, nil
}
switch kind {
case nfs4.ReturnFile:
if _, err = d.Uint64(); err != nil { // offset
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // length
return nil, 0, err
}
var st nfs4.Stateid
raw, rerr := d.Raw(16)
if rerr != nil {
return nil, 0, rerr
}
copy(st[:], raw)
if _, err = d.Uint32(); err != nil { // ffsid_info_type
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if _, status := h.layouts().lookup(st, reg.cur); status != nfs4.ErrOK {
return nil, status, nil
}
returned := st
returned[0]++
if status := h.layouts().drop(st); status != nfs4.ErrOK {
return nil, status, nil
}
return nfs4.AppendLayoutReturnRes(nil, returned), nfs4.ErrOK, nil
case nfs4.ReturnFsid, nfs4.ReturnAll:
h.layouts().dropClient(clientid)
h.layouts().dropSession(sessID)
return nfs4.AppendLayoutReturnRes(nil, nfs4.Stateid{}), nfs4.ErrOK, nil
default:
return nil, nfs4.ErrBadLayout, nil
}
}
// getDeviceInfoOp serves GETDEVICEINFO: the data server addresses the
// client needs to reach the storage behind the layout. The one device of
// this build is the metadata server itself.
func (h *Handler) getDeviceInfoOp(d *xdr.Decoder, ctx *connCB) ([]byte, uint32, error) {
var deviceID [16]byte
raw, err := d.Raw(16)
if err != nil {
return nil, 0, err
}
copy(deviceID[:], raw)
layoutType, err := d.Uint32()
if err != nil {
return nil, 0, err
}
maxcount, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if _, err = nfs4.ReadBitmap(d); err != nil { // notification types
return nil, 0, err
}
if layoutType != nfs4.LayoutTypeFlexfiles && layoutType != nfs4.LayoutTypeFlexFilesV2 {
return nil, nfs4.ErrUnknownLayoutType, nil
}
if deviceID != layoutDeviceID {
return nil, nfs4.ErrNoEnt, nil
}
// The address body follows the layout type: flexfiles carries the
// server list with versions, the files layout the stripe indices over
// the multipath list, the others the emulated volume or component.
var addr []byte
switch layoutType {
case nfs4.LayoutTypeFiles:
addr = nfs4.AppendFileDeviceAddr(nil, []uint32{0},
[]nfs4.NetAddr{{Netid: "tcp", Uaddr: h.deviceAddr(ctx)}})
default:
addr = nfs4.AppendFlexDeviceAddr(nil, nfs4.FlexDeviceAddr{
NetAddrs: []nfs4.NetAddr{{Netid: "tcp", Uaddr: h.deviceAddr(ctx)}},
Versions: []nfs4.FlexVersion{{
Version: 4,
MinorVersion: nfs4.MinorVersion,
RSize: uint32(nfs4.DefaultLimits.MaxRead),
WSize: uint32(nfs4.DefaultLimits.MaxWrite),
}},
})
}
res := nfs4.AppendGetDeviceInfoRes(nil, addr)
if maxcount != 0 && uint32(len(res)) > maxcount {
return nil, nfs4.ErrTooSmall, nil
}
return res, nfs4.ErrOK, nil
}
// deviceAddr resolves the universal address the data server answers on:
// the configured value wins, then the local address of the connection the
// request rode in on, then the loopback default.
func (h *Handler) deviceAddr(ctx *connCB) string {
if h.DeviceAddr != "" {
return h.DeviceAddr
}
if ctx != nil && ctx.conn != nil {
if u := uaddrOf(ctx.conn.LocalAddr().String()); u != "" {
return u
}
}
return "127.0.0.1.8.1"
}
// uaddrOf turns a host:port address into the universal address form of
// RFC 8435: decimal octets and port for IPv4, hex nibbles for IPv6.
func uaddrOf(hostPort string) string {
host, portText, err := net.SplitHostPort(hostPort)
if err != nil {
return ""
}
ip := net.ParseIP(host)
if ip == nil {
return ""
}
port := 0
if portText == "" {
return ""
}
for _, r := range portText {
if r < '0' || r > '9' {
return ""
}
port = port*10 + int(r-'0')
if port > 0xffff {
return ""
}
}
var v4 [4]byte
if n := copy(v4[:], ip.To4()); n == 4 {
return itoa(int(v4[0])) + "." + itoa(int(v4[1])) + "." + itoa(int(v4[2])) + "." +
itoa(int(v4[3])) + "." + itoa(port>>8) + "." + itoa(port&0xff)
}
v6 := ip.To16()
if v6 == nil {
return ""
}
const hexDigits = "0123456789abcdef"
out := make([]byte, 0, 16*3+8)
for i, b := range v6 {
if i > 0 {
out = append(out, '.')
}
out = append(out, hexDigits[b>>4], hexDigits[b&0xf])
}
out = append(out, '.')
out = append(out, itoa(port>>8)...)
out = append(out, '.')
out = append(out, itoa(port&0xff)...)
return string(out)
}
// itoa renders a small non negative number in decimal.
func itoa(n int) string {
if n == 0 {
return "0"
}
var digits [8]byte
i := len(digits)
for n > 0 {
i--
digits[i] = byte('0' + n%10)
n /= 10
}
return string(digits[i:])
}
// getDeviceListOp serves GETDEVICELIST: the one device of this build is
// the answer for every layout type it carries, RFC 5661 section 18.41.
func (h *Handler) getDeviceListOp(d *xdr.Decoder, ctx *connCB) ([]byte, uint32, error) {
layoutType, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // maxdevices
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // cookie
return nil, 0, err
}
if _, err = d.Raw(8); err != nil { // cookie verifier
return nil, 0, err
}
switch layoutType {
case nfs4.LayoutTypeFlexfiles, nfs4.LayoutTypeFlexFilesV2, nfs4.LayoutTypeFiles,
nfs4.LayoutTypeBlock, nfs4.LayoutTypeObjects, nfs4.LayoutTypeScsi:
return nfs4.AppendGetDeviceListRes(nil, 0, h.writeVerifier(),
[][16]byte{layoutDeviceID}, true), nfs4.ErrOK, nil
default:
return nil, nfs4.ErrUnknownLayoutType, nil
}
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// GETDEVICELIST answers the one device of this build for any layout
// type; LAYOUTGET accepts the emulated families and returns their wire
// bodies.
func TestEmulatedLayoutFamilies(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "fam", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
nfs4.AppendGetDeviceListArgs(nil, nfs4.LayoutTypeFlexfiles, 8, 0, [8]byte{}),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendGetDeviceListArgs(nil, nfs4.LayoutTypeFlexfiles, 8, 0, [8]byte{}),
})
wantStatus(t, "device list", res.Status, nfs4.ErrOK)
gd := xdr.NewDecoder(bodies[0])
if cookie, cerr := gd.Uint64(); cerr != nil || cookie != 0 {
t.Fatalf("cookie %d", cookie)
}
if _, cerr := gd.Raw(8); cerr != nil { // verifier
t.Fatal(cerr)
}
n, cerr := gd.Uint32()
if cerr != nil || n != 1 {
t.Fatalf("devices %d: %v", n, cerr)
}
raw, _ := gd.Raw(16)
if !bytes.Equal(raw, layoutDeviceID[:]) {
t.Fatalf("device % x", raw)
}
if eof, _ := gd.Bool(); !eof {
t.Fatal("eof false")
}
// The files layout body decodes with its device and fh list.
openSt := nfs4.Stateid{}
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x8888, []byte("fam"), nfs4.ShareAccessRead, 0, false, 0, "a.txt"),
nfs4.AppendGetfh(nil),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
copy(openSt[:], bodies[1])
fh, _ := xdr.NewDecoder(bodies[2]).VarOpaque()
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFiles, nfs4.IoModeRead, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "files layoutget", res.Status, nfs4.ErrOK)
ld := xdr.NewDecoder(bodies[1])
var st nfs4.Stateid
raw, _ = ld.Raw(16)
copy(st[:], raw)
if !bytes.HasPrefix(st[4:], []byte("LAYOUT")) {
t.Fatalf("stateid % x", st[4:])
}
if _, _ = ld.Bool(); ld.Remaining() == 0 {
t.Fatal("no layout segments")
}
if n, _ := ld.Uint32(); n != 1 { // the segment count
t.Fatalf("segment count %d", n)
}
if _, _ = ld.Uint64(); ld.Remaining() == 0 { // offset
t.Fatal("short segment")
}
if _, _ = ld.Uint64(); ld.Remaining() == 0 { // length
t.Fatal("short segment")
}
if _, _ = ld.Uint32(); ld.Remaining() == 0 { // iomode
t.Fatal("short segment")
}
if _, _ = ld.Uint32(); ld.Remaining() == 0 { // type
t.Fatal("short segment")
}
flex, ferr := ld.VarOpaque()
if ferr != nil {
t.Fatal(ferr)
}
// The files layout body: device, util, first stripe, pattern offset,
// the fh list with the file's own handle.
fd := xdr.NewDecoder(flex)
dev, _ := fd.Raw(16)
if !bytes.Equal(dev, layoutDeviceID[:]) {
t.Fatalf("body device % x len %d", dev, len(flex))
}
if _, _ = fd.Uint32(); fd.Remaining() == 0 { // util
t.Fatal("short files body")
}
if _, _ = fd.Uint32(); fd.Remaining() == 0 { // first stripe
t.Fatal("short files body")
}
if _, _ = fd.Uint64(); fd.Remaining() == 0 { // pattern offset
t.Fatal("short files body")
}
fn, _ := fd.Uint32()
if fn != 1 {
t.Fatalf("fh list %d", fn)
}
if got, _ := fd.VarOpaque(); !bytes.Equal(got, fh) {
t.Fatalf("fh % x", got)
}
// The block, objects and SCSI families answer the same way with
// their own bodies; the emulated device is always this server.
for _, typ := range []uint32{nfs4.LayoutTypeFlexFilesV2, nfs4.LayoutTypeBlock, nfs4.LayoutTypeObjects, nfs4.LayoutTypeScsi} {
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, typ, nfs4.IoModeRead, 0, ^uint64(0), 0, openSt, 1<<16),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("family %d: status %d", typ, res.Status)
}
}
}
// LAYOUTGET of the flexfiles version 2 type answers the version two
// body: the data server with its stateid array, the file handle and the
// AUTH_NONE credential of tight coupling.
func TestFlexFilesV2LayoutGet(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "ffv2", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x9999, []byte("ffv2"), nfs4.ShareAccessRead, 0, false, 0, "a.txt"),
nfs4.AppendGetfh(nil),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
var openSt nfs4.Stateid
copy(openSt[:], bodies[1])
fh, _ := xdr.NewDecoder(bodies[2]).VarOpaque()
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexFilesV2,
nfs4.IoModeRead, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "ffv2 layoutget", res.Status, nfs4.ErrOK)
ld := xdr.NewDecoder(bodies[1])
var st nfs4.Stateid
raw, _ := ld.Raw(16)
copy(st[:], raw)
if !bytes.HasPrefix(st[4:], []byte("LAYOUT")) {
t.Fatalf("stateid % x", st[4:])
}
if _, _ = ld.Bool(); ld.Remaining() == 0 {
t.Fatal("no segments")
}
if n, _ := ld.Uint32(); n != 1 {
t.Fatalf("segments %d", n)
}
if _, _ = ld.Uint64(); ld.Remaining() == 0 { // offset
t.Fatal("short segment")
}
if _, _ = ld.Uint64(); ld.Remaining() == 0 { // length
t.Fatal("short segment")
}
if _, _ = ld.Uint32(); ld.Remaining() == 0 { // iomode
t.Fatal("short segment")
}
if typ, _ := ld.Uint32(); typ != nfs4.LayoutTypeFlexFilesV2 {
t.Fatalf("segment type %d", typ)
}
v2, verr := ld.VarOpaque()
if verr != nil {
t.Fatal(verr)
}
fd := xdr.NewDecoder(v2)
if su, err := fd.Uint64(); err != nil || su != 0 {
t.Fatalf("stripe unit %d: %v", su, err)
}
if n, err := fd.Uint32(); err != nil || n != 1 {
t.Fatalf("mirrors %d: %v", n, err)
}
if n, err := fd.Uint32(); err != nil || n != 1 {
t.Fatalf("servers %d: %v", n, err)
}
raw, _ = fd.Raw(16)
if !bytes.Equal(raw, layoutDeviceID[:]) {
t.Fatalf("device % x", raw)
}
if eff, err := fd.Uint32(); err != nil || eff != 0 {
t.Fatalf("efficiency %d: %v", eff, err)
}
if n, err := fd.Uint32(); err != nil || n != 1 {
t.Fatalf("stateids %d: %v", n, err)
}
raw, _ = fd.Raw(16)
if !bytes.Equal(raw, st[:]) {
t.Fatalf("v2 stateid % x want % x", raw, st[:])
}
if n, err := fd.Uint32(); err != nil || n != 1 {
t.Fatalf("fhs %d: %v", n, err)
}
if v2fh, err := fd.VarOpaque(); err != nil || !bytes.Equal(v2fh, fh) {
t.Fatalf("v2 fh % x", v2fh)
}
if _, _ = fd.String(); fd.Remaining() == 0 { // user
t.Fatal("short v2 body")
}
if _, _ = fd.String(); fd.Remaining() == 0 { // group
t.Fatal("short v2 body")
}
if flavor, err := fd.Uint32(); err != nil || flavor != 0 {
t.Fatalf("auth flavor %d: %v", flavor, err)
}
if authBody, err := fd.VarOpaque(); err != nil || len(authBody) != 0 {
t.Fatalf("auth body % x", authBody)
}
// GETDEVICEINFO and GETDEVICELIST accept the version two type.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendGetDeviceInfoArgs(nil, layoutDeviceID,
nfs4.LayoutTypeFlexFilesV2, 1<<16, nfs4.Bitmap{}),
})
wantStatus(t, "ffv2 deviceinfo", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendGetDeviceListArgs(nil, nfs4.LayoutTypeFlexFilesV2, 8, 0, [8]byte{}),
})
wantStatus(t, "ffv2 devicelist", res.Status, nfs4.ErrOK)
}
+463
View File
@@ -0,0 +1,463 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
// A lockRange is one held byte range. A length of zero means to the end
// of the file, which the store normalises to the largest offset.
type lockRange struct {
offset uint64
length uint64
write bool
}
// A lockState is one lock owner's state on one file: the ranges it holds
// and the stateid the client uses for further LOCK and LOCKU calls.
type lockState struct {
stateid nfs4.Stateid
fileKey string
clientID uint64
owner []byte
ranges []lockRange
}
// lockStore keeps the byte range locks of the server. Locks of one owner
// never conflict with each other; overlapping locks of different owners
// do, when either side is a write lock.
type lockStore struct {
mu sync.Mutex
next uint64
locks map[string]*lockState // by stateid other
byFile map[string][]*lockState // by file key
byOwn map[string]*lockState // by file key + clientid + owner
waiters map[string][]*lockWaiter // denied locks by file key
}
func newLockStore() *lockStore {
return &lockStore{
next: randCounter(),
locks: make(map[string]*lockState),
byFile: make(map[string][]*lockState),
byOwn: make(map[string]*lockState),
}
}
// ownerKey builds the identity key of one lock owner on one file.
func ownerKey(fileKey string, clientid uint64, owner []byte) string {
return fileKey + "|" + clientidKey(clientid, owner)
}
// clientidKey renders the client identity part of an owner key.
func clientidKey(clientid uint64, owner []byte) string {
return ownerKeyHex(clientid) + "|" + string(owner)
}
func ownerKeyHex(clientid uint64) string {
return hexClientid(clientid)
}
func hexClientid(clientid uint64) string {
const digits = "0123456789abcdef"
out := make([]byte, 16)
for i := range 16 {
out[15-i] = digits[(clientid>>(4*i))&0xf]
}
return string(out)
}
// rangeEnd reports the exclusive end of a range, treating the zero length
// as to the end of the file.
func rangeEnd(r lockRange) uint64 {
if r.length == 0 {
return ^uint64(0)
}
return r.offset + r.length
}
// rangesConflict reports whether two ranges of the given kinds fight.
// Write locks fight everything they overlap; read locks fight only write
// locks.
func rangesConflict(a, b lockRange) bool {
if a.offset >= rangeEnd(b) || b.offset >= rangeEnd(a) {
return false
}
return a.write || b.write
}
// lock applies a LOCK of the given lock owner against the file. Locks of
// one owner coexist; overlapping locks of different owners are denied.
func (s *lockStore) lock(fh nfsfs.Handle, lockClientid uint64, lockOwner []byte, write bool, offset, length uint64) (nfs4.Stateid, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
probe := lockRange{offset: offset, length: length, write: write}
// A lock owner never conflicts with itself; every other live lock over
// an overlapping range denies the lock.
for _, other := range s.byFile[key] {
if sameIdentity(other, lockClientid, lockOwner) {
continue
}
for _, r := range other.ranges {
if rangesConflict(probe, r) {
return nfs4.Stateid{}, nfs4.ErrDenied
}
}
}
oid := ownerKey(key, lockClientid, lockOwner)
ls := s.byOwn[oid]
if ls == nil {
s.next++
ls = &lockState{stateid: lockStateid(s.next), fileKey: key, clientID: lockClientid, owner: lockOwner}
s.locks[string(ls.stateid[:])] = ls
s.byOwn[oid] = ls
s.byFile[key] = append(s.byFile[key], ls)
}
ls.ranges = addRange(ls.ranges, lockRange{offset: offset, length: length, write: write})
return ls.stateid, nfs4.ErrOK
}
// sameIdentity reports whether a lock state belongs to the given lock
// owner identity.
func sameIdentity(ls *lockState, clientid uint64, owner []byte) bool {
return ls != nil && ls.clientID == clientid && string(ls.owner) == string(owner)
}
// unlock releases the range of a lock stateid, splitting held ranges as
// needed, and bumps the stateid sequence. Only the client the lock
// state belongs to may release it. A cleared sequence names the
// current version, RFC 8881 section 8.2.2. Releasing a range that is
// not held is not an error.
func (s *lockStore) unlock(st nfs4.Stateid, clientid, offset, length uint64) (nfs4.Stateid, uint32) {
s.mu.Lock()
defer s.mu.Unlock()
ls, ok := s.locks[string(st[:])]
if !ok && stateidSeq(st) == 0 {
// A cleared sequence names the current version: resolve by the
// other field among the client's lock states.
for _, cand := range s.locks {
if string(cand.stateid[4:]) == string(st[4:]) && cand.clientID == clientid {
ls, ok = cand, true
break
}
}
}
if !ok {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if ls.clientID != clientid {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if length == 0 {
length = ^uint64(0) - offset
}
ls.ranges = subtractRange(ls.ranges, lockRange{offset: offset, length: length})
setStateidSeq(&ls.stateid, stateidSeq(ls.stateid)+1)
return ls.stateid, nfs4.ErrOK
}
// locksHeldOn reports whether any lock state of the file still holds
// ranges, which is the CLOSE guard.
func (s *lockStore) locksHeldOn(fh nfsfs.Handle) bool {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, ls := range s.byFile[key] {
if len(ls.ranges) > 0 {
return true
}
}
return false
}
// dropClient releases every lock state of the client, which DESTROY_CLIENT
// ID and lease expiry require.
func (s *lockStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for oid, ls := range s.byOwn {
if ls.clientID != clientid {
continue
}
delete(s.locks, string(ls.stateid[:]))
delete(s.byOwn, oid)
list := s.byFile[ls.fileKey]
for i, cand := range list {
if cand == ls {
s.byFile[ls.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
}
}
// lockStateid builds a fresh lock stateid: sequence one and the counter in
// the other field.
func lockStateid(counter uint64) nfs4.Stateid {
var st nfs4.Stateid
setStateidSeq(&st, 1)
st[4], st[5], st[6], st[7] = 'L', 'O', 'C', 'K'
for i := range 8 {
st[15-i] = byte(counter >> (8 * i))
}
return st
}
// addRange inserts a range into the held list, merging it with the
// overlapping and adjacent ranges of the same kind, RFC 8881 section
// 18.10: a new lock over a range the owner already holds replaces it,
// never piles a second entry on top. A mixed read and write overlap is
// resolved by subtraction first: the write form wins the intersection.
func addRange(ranges []lockRange, r lockRange) []lockRange {
// Carve the new range out of every held range of the other kind,
// then absorb it into the held ranges of its own kind.
var carved []lockRange
for _, held := range ranges {
if held.write == r.write {
carved = append(carved, held)
continue
}
end := rangeEnd(r)
heldEnd := rangeEnd(held)
if r.offset >= heldEnd || held.offset >= end {
carved = append(carved, held)
continue
}
if held.offset < r.offset {
carved = append(carved, lockRange{held.offset, r.offset - held.offset, held.write})
}
if heldEnd > end {
carved = append(carved, lockRange{end, heldEnd - end, held.write})
}
}
var out []lockRange
merged := r
for _, held := range carved {
if held.write != r.write {
out = append(out, held)
continue
}
if held.offset > rangeEnd(merged) || merged.offset > rangeEnd(held) {
out = append(out, held) // disjoint: keep both
continue
}
// Overlapping or adjacent: widen the merge, keeping both ends.
end := rangeEnd(merged)
if rangeEnd(held) > end {
end = rangeEnd(held)
}
if held.offset < merged.offset {
merged.offset = held.offset
}
if merged.length == 0 || held.length == 0 {
merged.length = 0 // spans to the end of the file
} else {
merged.length = end - merged.offset
}
}
return append(out, merged)
}
// subtractRange removes the interval of r from every held range, splitting
// the ranges it cuts through.
func subtractRange(ranges []lockRange, r lockRange) []lockRange {
end := rangeEnd(r)
var out []lockRange
for _, held := range ranges {
heldEnd := rangeEnd(held)
if r.offset >= rangeEnd(held) || held.offset >= end {
out = append(out, held)
continue
}
if held.offset < r.offset {
out = append(out, lockRange{held.offset, r.offset - held.offset, held.write})
}
if heldEnd > end {
out = append(out, lockRange{end, heldEnd - end, held.write})
}
}
return out
}
// byStateid resolves a lock state by its stateid, verifies that it
// names the current file and belongs to the asking client. A stateid
// presented with a cleared sequence names the current version, RFC
// 8881 section 8.2.2.
func (s *lockStore) byStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) (*lockState, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
ls, ok := s.locks[string(st[:])]
if !ok && stateidSeq(st) == 0 {
ls = s.byOther(st[4:], clientid, key)
if ls == nil {
return nil, nfs4.ErrBadStateid
}
}
if ls == nil {
return nil, nfs4.ErrBadStateid
}
if ls.fileKey != key || ls.clientID != clientid {
return nil, nfs4.ErrBadStateid
}
return ls, nfs4.ErrOK
}
// byOther resolves a lock state by its other field alone.
func (s *lockStore) byOther(other []byte, clientid uint64, key string) *lockState {
for _, ls := range s.locks {
if string(ls.stateid[4:]) == string(other) && ls.clientID == clientid && ls.fileKey == key {
return ls
}
}
return nil
}
// test serves LOCKT: it scans the locks of the current file for a conflict
// with the requested range, ignoring the locks of the probing owner, and
// reports the holder of the first conflicting lock.
func (s *lockStore) test(fh nfsfs.Handle, lockClientid uint64, lockOwner []byte, write bool, offset, length uint64) (nfs4.LockDenied, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
probe := lockRange{offset: offset, length: length, write: write}
for _, other := range s.byFile[key] {
if other.clientID == lockClientid && string(other.owner) == string(lockOwner) {
continue
}
for _, r := range other.ranges {
if !rangesConflict(probe, r) {
continue
}
denied := nfs4.LockDenied{
Offset: r.offset,
Length: r.length,
LockType: nfs4.LockTypeRead,
ClientID: other.clientID,
Owner: other.owner,
}
if r.write {
denied.LockType = nfs4.LockTypeWrite
}
return denied, nfs4.ErrDenied
}
}
return nfs4.LockDenied{}, nfs4.ErrOK
}
// releaseOwner retires every lock state of one lock owner, which
// RELEASE_LOCKOWNER requires. Locks the owner still holds go with it.
func (s *lockStore) releaseOwner(clientid uint64, owner []byte) {
s.mu.Lock()
defer s.mu.Unlock()
for oid, ls := range s.byOwn {
if ls.clientID != clientid || string(ls.owner) != string(owner) {
continue
}
delete(s.locks, string(ls.stateid[:]))
delete(s.byOwn, oid)
list := s.byFile[ls.fileKey]
for i, cand := range list {
if cand == ls {
s.byFile[ls.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
}
}
// freeStateid retires the lock state the stateid names, which
// FREE_STATEID requires after every lock of the owner is gone. Only the
// client the lock state belongs to may retire it.
func (s *lockStore) freeStateid(st nfs4.Stateid, clientid uint64) uint32 {
s.mu.Lock()
defer s.mu.Unlock()
ls, ok := s.locks[string(st[:])]
if !ok {
return nfs4.ErrBadStateid
}
if ls.clientID != clientid {
return nfs4.ErrBadStateid
}
if len(ls.ranges) > 0 {
return nfs4.ErrLocksHeld
}
delete(s.locks, string(ls.stateid[:]))
oid := ownerKey(ls.fileKey, ls.clientID, ls.owner)
if s.byOwn[oid] == ls {
delete(s.byOwn, oid)
}
list := s.byFile[ls.fileKey]
for i, cand := range list {
if cand == ls {
s.byFile[ls.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
return nfs4.ErrOK
}
// hasStateid reports whether the lock stateid is live.
func (s *lockStore) hasStateid(st nfs4.Stateid) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.locks[string(st[:])]
return ok
}
// A lockWaiter records one denied LOCK, so the server can notify the
// owner when a conflicting range is released: the CB_NOTIFY_LOCK of RFC
// 8881 section 20.6.
type lockWaiter struct {
clientID uint64
owner []byte
offset uint64
length uint64
write bool
}
// addWaiter records a denied lock request on the file.
func (s *lockStore) addWaiter(fh nfsfs.Handle, clientid uint64, owner []byte, offset, length uint64, write bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.waiters == nil {
s.waiters = make(map[string][]*lockWaiter)
}
s.waiters[fileKey(fh)] = append(s.waiters[fileKey(fh)], &lockWaiter{
clientID: clientid, owner: owner, offset: offset, length: length, write: write,
})
}
// takeWaiters returns and forgets every waiter of the file whose range
// overlaps the just released range: their conflict may be gone.
func (s *lockStore) takeWaiters(fh nfsfs.Handle, offset, length uint64) []*lockWaiter {
s.mu.Lock()
defer s.mu.Unlock()
key := fileKey(fh)
list := s.waiters[key]
if len(list) == 0 {
return nil
}
delete(s.waiters, key)
end := offset + length
if length == 0 {
end = ^uint64(0)
}
var out []*lockWaiter
for _, w := range list {
wEnd := w.offset + w.length
if w.length == 0 {
wEnd = ^uint64(0)
}
if w.offset < end && offset < wEnd {
out = append(out, w)
}
}
return out
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Migration and referrals of RFC 5661 section 11: names the handler
// marks as referrals resolve to stub handles whose GETATTR answers the
// fs_locations and fs_locations_info attributes and whose other
// operations answer NFS4ERR_MOVED.
package nfs4server
import (
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
// A Referral names the file system the client is sent to: the replica
// server list and the path of the referred root on those servers.
type Referral struct {
Servers []string
RootPath []string
}
// referralStore keeps the configured referrals by name and the stub
// handles issued for them.
type referralStore struct {
mu sync.Mutex
byRef map[string]Referral // by component name
byKey map[string]nfsfs.Handle // by component name: the stub
names map[string]string // by stub handle: the component
next uint64
}
func newReferralStore() *referralStore {
return &referralStore{
byRef: make(map[string]Referral),
byKey: make(map[string]nfsfs.Handle),
names: make(map[string]string),
}
}
// put registers a referral and answers its stable stub handle.
func (s *referralStore) put(name string, r Referral) nfsfs.Handle {
s.mu.Lock()
defer s.mu.Unlock()
s.byRef[name] = r
if stub, ok := s.byKey[name]; ok {
return stub
}
s.next++
stub := nfsfs.Handle(append([]byte("REF/"), []byte(name)...))
s.byKey[name] = stub
s.names[string(stub)] = name
return stub
}
// byName resolves the referral configured for a component name.
func (s *referralStore) byName(name string) (Referral, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.byRef[name]
return r, ok
}
// byHandle resolves the referral a stub handle names.
func (s *referralStore) byHandle(fh nfsfs.Handle) (Referral, bool) {
s.mu.Lock()
defer s.mu.Unlock()
name, ok := s.names[string(fh)]
if !ok {
return Referral{}, false
}
r, ok := s.byRef[name]
return r, ok
}
// referrals returns the referral store, made once per handler.
func (h *Handler) referrals() *referralStore {
h.mu.Lock()
defer h.mu.Unlock()
if h.refSt == nil {
h.refSt = newReferralStore()
}
return h.refSt
}
// SetReferral registers a referral: LOOKUP of the component name lands
// on a stub whose GETATTR answers the location attributes and whose
// other operations answer NFS4ERR_MOVED.
func (h *Handler) SetReferral(name string, r Referral) {
h.referrals().put(name, r)
}
// isReferralStub reports whether the handle names a referral stub.
func (h *Handler) isReferralStub(fh nfsfs.Handle) bool {
_, ok := h.referrals().byHandle(fh)
return ok
}
// stubAttrs builds the attributes of a referral stub: an absent
// directory whose location attributes carry the referral.
func (h *Handler) stubAttrs(fh nfsfs.Handle) nfs4.Attrs {
r, _ := h.referrals().byHandle(fh)
fsRoot := []string{""}
a := nfs4.Attrs{
Type: nfs4.NF4Dir,
FHExpireType: nfs4.FH4Persistent,
NamedAttr: false,
UniqueHandles: true,
}
var servers []nfs4.FsServer
for i, s := range r.Servers {
currency := int32(0)
info := []byte{rpcNoneInfo}
if i == 0 {
info[0] |= nfs4.Fsli4gfCurReq
}
info[0] |= nfs4.Fsli4gfWritable
servers = append(servers, nfs4.FsServer{Currency: currency, Info: info, Server: s})
}
a.FsLocations = nfs4.AppendFsLocations(nil, fsRoot, []nfs4.FsLocation{{
Servers: r.Servers,
RootPath: r.RootPath,
}})
a.FsLocationsInfo = nfs4.AppendFsLocationsInfo(nil, 0, 60, fsRoot,
[]nfs4.FsItem{{Entries: servers, RootPath: r.RootPath}})
return a
}
// The value of the fls_info class byte that marks this build's single
// homogeneous replica set.
const rpcNoneInfo = 0x00
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// A configured referral answers LOOKUP with a stub, GETATTR with the
// location attributes, and everything that touches the backend with
// NFS4ERR_MOVED, RFC 5661 sections 8.4.2 and 11.9.
func TestReferralAndMoved(t *testing.T) {
h := testTree(t)
h.SetReferral("migrated", Referral{
Servers: []string{"10.0.0.9.8.1", "10.0.0.9.8.2"},
RootPath: []string{"exports", "data"},
})
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "ref", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// LOOKUP lands on the stub and GETATTR carries the locations.
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "migrated"),
// The blob carries only the one attribute: attributes encode in
// numeric order, so the test reads them one call at a time.
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrFsLocations)),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lookup stub: status %d", res.Status)
}
ad := xdr.NewDecoder(bodies[2])
words, aerr := ad.Uint32() // the response bitmap: count then words
if aerr != nil {
t.Fatal(aerr)
}
if _, rerr := ad.Raw(int(words) * 4); rerr != nil {
t.Fatal(rerr)
}
blob, aerr := ad.VarOpaque()
if aerr != nil {
t.Fatal(aerr)
}
fsRoot, locations, err := nfs4.DecodeFsLocations(blob)
if err != nil {
t.Fatalf("decode fs_locations: %v", err)
}
if len(locations) != 1 || len(locations[0].Servers) != 2 ||
locations[0].Servers[1] != "10.0.0.9.8.2" {
t.Fatalf("locations %+v", locations)
}
if len(fsRoot) != 1 || fsRoot[0] != "" {
t.Fatalf("fs root %v", fsRoot)
}
// A write against the stub is MOVED.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "migrated"),
nfs4.AppendWriteArgs(nil, nfs4.Stateid{}, 0, nfs4.StableFileSync, []byte("x")),
})
if res.Status != nfs4.ErrMoved {
t.Fatalf("write on stub: status %d, want MOVED", res.Status)
}
// The real tree still serves files under their own names.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrSize)),
})
wantStatus(t, "real file", res.Status, nfs4.ErrOK)
}
+223
View File
@@ -0,0 +1,223 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Named attributes of RFC 8881 section 5.3: the OPENATTR operation
// opens a synthetic directory of an object's extended attributes, and
// the file operations over the synthetic handles map to the backend's
// xattr half.
package nfs4server
import (
"strings"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
// The synthetic handle prefixes of the named attribute space.
const (
nattrDirPrefix = "NAD/" // the named attribute directory of an object
nattrFilePrefix = "NAF/" // one named attribute of an object
)
// nattrStore keeps the mapping between synthetic handles and the object
// they hang from, so a named attribute file remembers its parent.
type nattrStore struct {
mu sync.Mutex
dirs map[string]nfsfs.Handle // parent object -> dir handle
}
func newNattrStore() *nattrStore {
return &nattrStore{dirs: make(map[string]nfsfs.Handle)}
}
func (s *nattrStore) dirFor(parent nfsfs.Handle) nfsfs.Handle {
s.mu.Lock()
defer s.mu.Unlock()
if d, ok := s.dirs[string(parent)]; ok {
return d
}
d := nfsfs.Handle(append([]byte(nattrDirPrefix), parent...))
s.dirs[string(parent)] = d
return d
}
// dirOf pulls the directory handle and the attribute name out of a
// synthetic file handle.
func (s *nattrStore) fileFor(dir, name string) nfsfs.Handle {
s.mu.Lock()
defer s.mu.Unlock()
return nfsfs.Handle(nattrFilePrefix + name + "/" + dir)
}
func (s *nattrStore) dirOf(file nfsfs.Handle) (nfsfs.Handle, string, bool) {
text := string(file)
if !strings.HasPrefix(text, nattrFilePrefix) {
return nil, "", false
}
text = text[len(nattrFilePrefix):]
slash := strings.IndexByte(text, '/')
if slash < 0 {
return nil, "", false
}
return nfsfs.Handle(text[slash+1:]), text[:slash], true
}
// parentOf pulls the parent object out of a synthetic directory handle.
func (s *nattrStore) parentOf(dir nfsfs.Handle) (nfsfs.Handle, bool) {
text := string(dir)
if !strings.HasPrefix(text, nattrDirPrefix) {
return nil, false
}
return nfsfs.Handle(text[len(nattrDirPrefix):]), true
}
// nattrs returns the named attribute store, made once per handler.
func (h *Handler) nattrs() *nattrStore {
h.mu.Lock()
defer h.mu.Unlock()
if h.nattrSt == nil {
h.nattrSt = newNattrStore()
}
return h.nattrSt
}
// openattrOp serves OPENATTR: the current object's named attribute
// directory answers as a synthetic handle, RFC 8881 section 18.16.
func (h *Handler) openattrOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
if _, err := d.Bool(); err != nil { // created
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return nil, nfs4.ErrNotSupp, nil
}
reg.cur, reg.haveCur = h.nattrs().dirFor(reg.cur), true
return nil, nfs4.ErrOK, nil
}
// isNattrDir reports whether the handle names a named attribute
// directory.
func (h *Handler) isNattrDir(fh nfsfs.Handle) bool {
_, ok := h.nattrs().parentOf(fh)
return ok
}
// isNattrFile reports whether the handle names a named attribute file.
func (h *Handler) isNattrFile(fh nfsfs.Handle) bool {
_, _, ok := h.nattrs().dirOf(fh)
return ok
}
// nattrLookup resolves one attribute name inside the synthetic
// directory; the attribute must exist.
func (h *Handler) nattrLookup(dir nfsfs.Handle, name string) (nfsfs.Handle, uint32) {
parent, ok := h.nattrs().parentOf(dir)
if !ok {
return nil, nfs4.ErrBadHandle
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return nil, nfs4.ErrNotSupp
}
if _, err := x.GetXattr(parent, name, 0); err == nfsfs.ErrNoXattr {
return nil, nfs4.ErrNoEnt
} else if err != nil && err != nfsfs.ErrXattrNotSupp {
return nil, nfs4.ErrNoEnt
}
return h.nattrs().fileFor(string(dir), name), nfs4.ErrOK
}
// nattrRead reads the value of a named attribute file.
func (h *Handler) nattrRead(fh nfsfs.Handle, offset uint64) ([]byte, uint32) {
dir, name, ok := h.nattrs().dirOf(fh)
if !ok {
return nil, nfs4.ErrBadHandle
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return nil, nfs4.ErrNotSupp
}
parent, ok := h.nattrs().parentOf(dir)
if !ok {
return nil, nfs4.ErrBadHandle
}
value, err := x.GetXattr(parent, name, 0)
if err == nfsfs.ErrNoXattr {
return nil, nfs4.ErrNoEnt
}
if err != nil {
return nil, mapErr(err)
}
if offset >= uint64(len(value)) {
return nil, nfs4.ErrOK
}
return value[offset:], nfs4.ErrOK
}
// nattrWrite writes the value of a named attribute file; named
// attributes rewrite whole, so an offset is refused.
func (h *Handler) nattrWrite(fh nfsfs.Handle, offset uint64, data []byte) (uint32, uint32) {
dir, name, ok := h.nattrs().dirOf(fh)
if !ok {
return 0, nfs4.ErrBadHandle
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return 0, nfs4.ErrNotSupp
}
parent, ok := h.nattrs().parentOf(dir)
if !ok {
return 0, nfs4.ErrBadHandle
}
if offset != 0 {
return 0, nfs4.ErrInval
}
if err := x.SetXattr(parent, name, data, nfsfs.XattrModeReplace); err != nil {
return 0, mapErr(err)
}
return uint32(len(data)), nfs4.ErrOK
}
// nattrCreate makes one named attribute with its initial value, the
// CREATE of the synthetic directory.
func (h *Handler) nattrCreate(dir nfsfs.Handle, name string, value []byte) uint32 {
parent, ok := h.nattrs().parentOf(dir)
if !ok {
return nfs4.ErrBadHandle
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return nfs4.ErrNotSupp
}
if err := x.SetXattr(parent, name, value, nfsfs.XattrModeCreate); err != nil {
return mapErr(err)
}
return nfs4.ErrOK
}
// nattrRemove deletes one named attribute of the synthetic directory.
func (h *Handler) nattrRemove(dir nfsfs.Handle, name string) uint32 {
parent, ok := h.nattrs().parentOf(dir)
if !ok {
return nfs4.ErrBadHandle
}
x, _ := h.FS.(nfsfs.XattrFS)
if x == nil {
return nfs4.ErrNotSupp
}
if err := x.RemoveXattr(parent, name); err != nil {
if err == nfsfs.ErrNoXattr {
return nfs4.ErrNoEnt
}
return mapErr(err)
}
return nfs4.ErrOK
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// OPENATTR opens the synthetic named attribute directory of a.txt, and
// CREATE, LOOKUP, READ and REMOVE over it map to the object's extended
// attributes, RFC 8881 section 5.3.
func TestNamedAttributes(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "nattr", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// OPENATTR on a.txt answers the synthetic directory.
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendOpenattrArgs(nil, false),
nfs4.AppendGetfh(nil),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("openattr: status %d", res.Status)
}
dirHandle, derr := xdrDecoder(bodies[3])
if derr != nil || len(dirHandle) == 0 {
t.Fatalf("dir handle: %v", derr)
}
// CREATE makes the attribute with its initial size, WRITE fills it.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, dirHandle),
nfs4.AppendCreateArgs(nil, nfs4.NF4Reg, "user.comment", "", 0, 0,
0o644),
})
wantStatus(t, "nattr create", res.Status, nfs4.ErrOK)
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, dirHandle),
nfs4.AppendLookup(nil, "user.comment"),
nfs4.AppendGetfh(nil),
})
wantStatus(t, "nattr lookup", res.Status, nfs4.ErrOK)
fileHandle, derr := xdrDecoder(bodies[2])
if derr != nil {
t.Fatal(derr)
}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fileHandle),
nfs4.AppendWriteArgs(nil, nfs4.Stateid{}, 0, nfs4.StableFileSync, []byte("hello")),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("nattr write: status %d", res.Status)
}
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fileHandle),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, 0, 64),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("nattr read: status %d", res.Status)
}
rd := xdr.NewDecoder(bodies[1])
if eof, rerr := rd.Bool(); rerr != nil || !eof {
t.Fatalf("eof %v: %v", eof, rerr)
}
value, rerr := rd.VarOpaque()
if rerr != nil || string(value) != "hello" {
t.Fatalf("value %q: %v", value, rerr)
}
// The value is visible through the xattr half of the same object.
fh := mustLookup(t, h, "a.txt")
got, gerr := h.FS.(nfsfs.XattrFS).GetXattr(fh, "user.comment", 0)
if gerr != nil || string(got) != "hello" {
t.Fatalf("xattr view %q: %v", got, gerr)
}
// REMOVE deletes the attribute.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, dirHandle),
nfs4.AppendRemoveArgs(nil, "user.comment"),
})
wantStatus(t, "nattr remove", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, dirHandle),
nfs4.AppendLookup(nil, "user.comment"),
})
wantStatus(t, "nattr lookup gone", res.Status, nfs4.ErrNoEnt)
}
// xdrDecoder decodes the file handle out of a GETFH result body.
func xdrDecoder(body []byte) ([]byte, error) {
return xdr.NewDecoder(body).VarOpaque()
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The handlers of the remaining NFSv4.1 operations: the parent lookup,
// the attribute comparisons, the lock owner retirement and the session
// binding family.
package nfs4server
import (
"errors"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// maxOps bounds the TEST_STATEID array, matching the COMPOUND bound of
// the wire codecs.
const maxOps = 1024
// lookuppOp serves LOOKUPP: the current handle becomes its parent
// directory.
func (h *Handler) lookuppOp(reg *fhreg) ([]byte, uint32, error) {
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
parent, _, err := h.FS.Parent(reg.cur)
if err != nil {
return nil, mapErr(err), nil
}
reg.cur, reg.haveCur = parent, true
return nil, nfs4.ErrOK, nil
}
// verifyOp serves VERIFY and NVERIFY: the compound proceeds only when the
// file carries, or with the negated form does not carry, every attribute
// the arguments assert. A value this build cannot compare answers
// ATTRNOTSUPP.
func (h *Handler) verifyOp(d *xdr.Decoder, reg *fhreg, negate bool) ([]byte, uint32, error) {
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
request, err := nfs4.ReadBitmap(d)
if err != nil {
return nil, 0, err
}
blob, err := d.VarOpaque()
if err != nil {
return nil, 0, err
}
updates, err := nfs4.DecodeSetattrBlob(blob, request)
if err != nil {
if err == nfs4.ErrAttrNotSettable {
return nil, nfs4.ErrAttrNotSupp, nil
}
return nil, 0, err
}
info, ferr := h.FS.Getattr(reg.cur)
if ferr != nil {
return nil, mapErr(ferr), nil
}
mismatch := false
switch {
case updates.HasMode && uint32(info.Mode.Perm()) != updates.Mode:
mismatch = true
case updates.HasSize && uint64(info.Size) != updates.Size:
mismatch = true
case updates.UID != nil && info.UID != *updates.UID:
mismatch = true
case updates.GID != nil && info.GID != *updates.GID:
mismatch = true
case updates.Atime != nil && !nfsTimeMatches(updates.Atime, info):
mismatch = true
case updates.Mtime != nil && !nfsTimeMatches(updates.Mtime, info):
mismatch = true
}
if mismatch == negate {
return nil, nfs4.ErrOK, nil
}
if negate {
return nil, nfs4.ErrSame, nil
}
return nil, nfs4.ErrNotSame, nil
}
// nfsTimeMatches reports whether the asserted time is the file time the
// backend reports. A server time assertion never matches a fixed value;
// only the seconds and nanoseconds a client carries count.
func nfsTimeMatches(set *nfs4.NfsTimeSet, info nfsfs.Info) bool {
if set.Server {
return false
}
got := info.ModTime
return got.Unix() == set.Time.Seconds && int64(uint32(got.Nanosecond())) == int64(set.Time.Nseconds)
}
// errTooManyStateids marks a TEST_STATEID whose array exceeds the bound
// every COMPOUND array of this build obeys.
var errTooManyStateids = errors.New("nfs4server: too many stateids in one test")
// releaseLockOwnerOp serves RELEASE_LOCKOWNER: every lock state the
// owner holds dies with the owner. The owner must belong to the asking
// client, RFC 8881 section 18.38.
func (h *Handler) releaseLockOwnerOp(d *xdr.Decoder, sessionClientid uint64) ([]byte, uint32, error) {
clientid, err := d.Uint64()
if err != nil {
return nil, 0, err
}
owner, err := d.VarOpaque()
if err != nil {
return nil, 0, err
}
if sessionClientid != 0 && clientid != sessionClientid {
return nil, nfs4.ErrBadOwner, nil
}
h.locks().releaseOwner(clientid, owner)
return nil, nfs4.ErrOK, nil
}
// delegReturnOp serves DELEGRETURN: the client gives its own delegation
// back. A stateid the store holds is dropped; anything else is answered
// OK just the same, because the release of a state the server does not
// carry is already done and the reply only tells the client to forget
// the stateid.
func (h *Handler) delegReturnOp(d *xdr.Decoder, sessionClientid uint64) ([]byte, uint32, error) {
var st nfs4.Stateid
raw, err := d.Raw(16)
if err != nil {
return nil, 0, err
}
copy(st[:], raw)
h.delegs().dropStateid(st, sessionClientid)
return nil, nfs4.ErrOK, nil
}
// backchannelCtlOp serves BACKCHANNEL_CTL: the client names the program
// its future back channels run.
func (h *Handler) backchannelCtlOp(d *xdr.Decoder, ctx *connCB) ([]byte, uint32, error) {
program, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if ctx != nil {
ctx.setProgram(program)
}
return nil, nfs4.ErrOK, nil
}
// bindConnToSessionOp serves BIND_CONN_TO_SESSION: the connection the
// request rode in on joins the session as a fore channel, a back channel
// or both. The session must belong to the asking client, so one client
// cannot graft its connection onto another's callbacks, and the
// callback program comes from CREATE_SESSION, which stores it with the
// session.
func (h *Handler) bindConnToSessionOp(d *xdr.Decoder, ctx *connCB, sessionClientid uint64) ([]byte, uint32, error) {
var id nfs4.SessionID
raw, err := d.Raw(16)
if err != nil {
return nil, 0, err
}
copy(id[:], raw)
dir, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if _, err = d.Bool(); err != nil { // use conn in RDMA mode
return nil, 0, err
}
if _, status := h.sessions().lookupSession(id); status != nfs4.ErrOK {
return nil, status, nil
}
// A compound that carries a session may bind only its own: one
// client must not graft its connection onto another's callbacks.
if sessionClientid != 0 && id.ClientIDOf() != sessionClientid {
return nil, nfs4.ErrBadSession, nil
}
switch dir {
case nfs4.Cdfc4Back, nfs4.Cdfc4BackOrBoth:
if ctx == nil {
return nil, nfs4.ErrConnNotBound, nil
}
h.sessions().attachCB(id, ctx)
return nfs4.AppendBindConnToSessionRes(nil, id, nfs4.Cdfs4Both), nfs4.ErrOK, nil
case nfs4.Cdfc4Fore, nfs4.Cdfc4ForeOrBoth:
return nfs4.AppendBindConnToSessionRes(nil, id, nfs4.Cdfs4Fore), nfs4.ErrOK, nil
default:
return nil, nfs4.ErrInval, nil
}
}
// freeStateidOp serves FREE_STATEID: the client retires a lock stateid
// of its own whose owner holds no locks any more.
func (h *Handler) freeStateidOp(d *xdr.Decoder, sessionClientid uint64) ([]byte, uint32, error) {
var st nfs4.Stateid
raw, err := d.Raw(16)
if err != nil {
return nil, 0, err
}
copy(st[:], raw)
if status := h.locks().freeStateid(st, sessionClientid); status != nfs4.ErrOK {
return nil, status, nil
}
return nil, nfs4.ErrOK, nil
}
// testStateidOp serves TEST_STATEID: one status per stateid, no state
// touched.
func (h *Handler) testStateidOp(d *xdr.Decoder) ([]byte, uint32, error) {
n, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if n > maxOps {
return nil, 0, errTooManyStateids
}
var statuses []uint32
for range n {
var st nfs4.Stateid
raw, rerr := d.Raw(16)
if rerr != nil {
return nil, 0, rerr
}
copy(st[:], raw)
status := h.openStates().testStateid(st)
if status == nfs4.ErrBadStateid {
switch {
case h.locks().hasStateid(st):
status = nfs4.ErrOK
case h.delegs().hasStateid(st):
status = nfs4.ErrOK
}
}
statuses = append(statuses, status)
}
return nfs4.AppendTestStateidRes(nil, statuses), nfs4.ErrOK, nil
}
+354
View File
@@ -0,0 +1,354 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestOps41Matrix(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "ops41", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// LOOKUPP walks to the parent: the root is its own parent.
var rootFH []byte
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetfh(nil),
})
rootFH, err := xdr.NewDecoder(bodies[1]).VarOpaque()
if err != nil {
t.Fatal(err)
}
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "sub"),
nfs4.AppendLookupp(nil),
nfs4.AppendGetfh(nil),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("lookupp: status %d", res.Status)
}
got, derr := xdr.NewDecoder(bodies[3]).VarOpaque()
if derr != nil || !bytes.Equal(got, rootFH) {
t.Fatalf("lookupp landed outside the root: % x %v", got, derr)
}
// PUTPUBFH answers the same handle as PUTROOTFH.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutPubfh(nil),
nfs4.AppendGetfh(nil),
})
wantStatus(t, "putpubfh", res.Status, nfs4.ErrOK)
pub, derr := xdr.NewDecoder(bodies[1]).VarOpaque()
if derr != nil || !bytes.Equal(pub, rootFH) {
t.Fatalf("public handle differs from the root")
}
// VERIFY passes when the file matches and fails with NOT_SAME when it
// does not; NVERIFY is the exact opposite.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize),
nfs4.Attrs{Mode: 0o644, Size: 9}),
})
wantStatus(t, "verify pass", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 8}),
})
wantStatus(t, "verify mismatch", res.Status, nfs4.ErrNotSame)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 8}),
})
wantStatus(t, "nverify pass", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 9}),
})
wantStatus(t, "nverify match", res.Status, nfs4.ErrSame)
// Open the file for the stateful half of the matrix.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x3333, []byte("ops41-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
var openSt nfs4.Stateid
copy(openSt[:], bodies[1])
var delegSt nfs4.Stateid
if len(bodies[1]) >= 68 {
copy(delegSt[:], bodies[1][52:68])
}
// DELEGRETURN hands the delegation back; a second return is answered
// OK the same way, the release of nothing is already done.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, []byte("ignored")),
nfs4.AppendDelegReturnArgs(nil, delegSt),
})
if res.Status != nfs4.ErrOK && res.Status != nfs4.ErrBadStateid {
t.Fatalf("deleg return: status %d", res.Status)
}
if res.Status == nfs4.ErrOK {
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendDelegReturnArgs(nil, delegSt),
})
wantStatus(t, "deleg return again", res.Status, nfs4.ErrOK)
}
// A byte range lock gives the lock owner a stateid; LOCKT of a second
// owner sees it, RELEASE_LOCKOWNER removes it.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openSt, 0x3333, []byte("ops41-locker"),
nfs4.LockTypeWrite, false, 0, 10),
})
wantStatus(t, "lock", res.Status, nfs4.ErrOK)
var lockSt nfs4.Stateid
copy(lockSt[:], bodies[2])
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLocktArgs(nil, nfs4.LockTypeWrite, 0, 10, 0x9999, []byte("other")),
})
wantStatus(t, "lockt sees the lock", res.Status, nfs4.ErrDenied)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendReleaseLockOwnerArgs(nil, sid.ClientIDOf(), []byte("ops41-locker")),
})
wantStatus(t, "release lock owner", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLocktArgs(nil, nfs4.LockTypeWrite, 0, 10, 0x9999, []byte("other")),
})
wantStatus(t, "lockt after release", res.Status, nfs4.ErrOK)
// FREE_STATEID refuses a stateid that never existed and the one that
// was just dropped.
seq++
res, _ = run(seq, [][]byte{nfs4.AppendFreeStateidArgs(nil, lockSt)})
if res.Status != nfs4.ErrOK && res.Status != nfs4.ErrBadStateid {
t.Fatalf("free stateid: status %d", res.Status)
}
seq++
res, _ = run(seq, [][]byte{nfs4.AppendFreeStateidArgs(nil, lockSt)})
wantStatus(t, "free stateid again", res.Status, nfs4.ErrBadStateid)
// TEST_STATEID answers one status per stateid: the live open, a dead
// stateid and the anonymous form.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendTestStateidArgs(nil, []nfs4.Stateid{openSt, lockSt, {}}),
})
wantStatus(t, "test stateid", res.Status, nfs4.ErrOK)
td := xdr.NewDecoder(bodies[0])
count, terr := td.Uint32()
if terr != nil || count != 3 {
t.Fatalf("statuses %d: %v", count, terr)
}
s0, _ := td.Uint32()
s1, _ := td.Uint32()
s2, _ := td.Uint32()
if s0 != nfs4.ErrOK || s1 != nfs4.ErrBadStateid || s2 != nfs4.ErrOK {
t.Fatalf("statuses %d %d %d", s0, s1, s2)
}
// BIND_CONN_TO_SESSION refuses a session that does not exist.
seq++
badSID := nfs4.SessionID{}
res, _ = run(seq, [][]byte{
nfs4.AppendBindConnToSessionArgs(nil, badSID, nfs4.Cdfc4Fore, false),
})
wantStatus(t, "bind unknown session", res.Status, nfs4.ErrBadSession)
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendBindConnToSessionArgs(nil, sid, nfs4.Cdfc4Fore, false),
})
wantStatus(t, "bind fore", res.Status, nfs4.ErrOK)
bd := xdr.NewDecoder(bodies[0])
var echoed nfs4.SessionID
raw, rerr := bd.Raw(16)
if rerr != nil {
t.Fatal(rerr)
}
copy(echoed[:], raw)
if echoed != sid {
t.Fatal("bind echoed another session")
}
if dir, _ := bd.Uint32(); dir != nfs4.Cdfs4Fore {
t.Fatalf("dir %d", dir)
}
if bd.Remaining() != 0 {
t.Fatalf("%d bytes left in the bind result", bd.Remaining())
}
// BACKCHANNEL_CTL records the future back channel program.
seq++
res, _ = run(seq, [][]byte{nfs4.AppendBackchannelCtlArgs(nil, 0x40000001)})
wantStatus(t, "backchannel ctl", res.Status, nfs4.ErrOK)
}
func TestVerifyTimeAndBareLookupp(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "verify", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// LOOKUPP without a current handle answers NOFILE_HANDLE.
seq++
res, _ := run(seq, [][]byte{nfs4.AppendLookupp(nil)})
wantStatus(t, "lookupp bare", res.Status, nfs4.ErrNoFileHandle)
// The modify time of a.txt asserts against itself: VERIFY passes,
// NVERIFY answers SAME; a wrong time flips both answers.
info, err := h.FS.Getattr(mustLookup(t, h, "a.txt"))
if err != nil {
t.Fatal(err)
}
modified := nfs4.NfsTime{Seconds: info.ModTime.Unix(), Nseconds: uint32(info.ModTime.Nanosecond())}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify),
nfs4.Attrs{TimeModify: modified}),
})
wantStatus(t, "verify time", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendNverifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify),
nfs4.Attrs{TimeModify: modified}),
})
wantStatus(t, "nverify time match", res.Status, nfs4.ErrSame)
modified.Nseconds++
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrTimeModify),
nfs4.Attrs{TimeModify: modified}),
})
wantStatus(t, "verify time mismatch", res.Status, nfs4.ErrNotSame)
}
func TestOps41EdgeBranches(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "edge", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// A VERIFY of an attribute this server refuses to compare answers
// ATTRNOTSUPP.
seq++
res, _ := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendVerifyArgs(nil, nfs4.OfBits(nfs4.AttrMountedOnFileID),
nfs4.Attrs{MountedOnFileID: 1}),
})
wantStatus(t, "verify unsupported attr", res.Status, nfs4.ErrAttrNotSupp)
// A nonsense connection direction answers INVAL.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendBindConnToSessionArgs(nil, sid, 9, false),
})
wantStatus(t, "bind direction", res.Status, nfs4.ErrInval)
// FREE_STATEID of a lock owner that still holds ranges answers
// LOCKS_HELD.
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x4444, []byte("edge-owner"),
nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
openSt := bodyStateid(bodies[1])
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, mustLookup(t, h, "a.txt")),
nfs4.AppendLockArgsNew(nil, openSt, 0x4444,
[]byte("edge-locker"), nfs4.LockTypeWrite, false, 0, 10),
})
wantStatus(t, "lock", res.Status, nfs4.ErrOK)
lockSt := bodyStateid(bodies[1])
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendFreeStateidArgs(nil, lockSt),
})
wantStatus(t, "free with locks held", res.Status, nfs4.ErrLocksHeld)
}
// bodyStateid reads the stateid that opens every stateful result body.
func bodyStateid(body []byte) nfs4.Stateid {
var st nfs4.Stateid
copy(st[:], body)
return st
}
+600
View File
@@ -0,0 +1,600 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The handlers of the NFSv4.2 operations, RFC 7862.
package nfs4server
import (
"errors"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// A seeker is the backend's hole seeking half: the answer offset, the
// eof flag that names the virtual hole at the end of every file, and
// ErrBeyondEOF for a request past the end, RFC 7862 section 15.11.
type seeker interface {
SeekHole(nfsfs.Handle, int64) (int64, bool, error)
SeekData(nfsfs.Handle, int64) (int64, bool, error)
}
// seeker resolves the backend's hole seeking half.
func (h *Handler) seeker() seeker {
s, _ := h.FS.(seeker)
return s
}
// allocator resolves the backend's space reservation half.
func (h *Handler) allocator() interface {
Allocate(nfsfs.Handle, int64, int64) error
Deallocate(nfsfs.Handle, int64, int64) error
} {
a, _ := h.FS.(interface {
Allocate(nfsfs.Handle, int64, int64) error
Deallocate(nfsfs.Handle, int64, int64) error
})
return a
}
// readStateid pulls one stateid off the wire.
func readStateid(d *xdr.Decoder) (nfs4.Stateid, error) {
var st nfs4.Stateid
raw, err := d.Raw(16)
if err != nil {
return st, err
}
copy(st[:], raw)
return st, nil
}
// checkOpStateid validates the stateid of a stateful data operation:
// it routes by the family mark to the store that minted it, the
// caller's own open of the file, the caller's own lock state on it, or
// the caller's own delegation of it, RFC 8881 section 10.3.
func (h *Handler) checkOpStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) uint32 {
switch string(st[4:8]) {
case "LOCK":
if _, status := h.locks().byStateid(st, fh, clientid); status != nfs4.ErrOK {
return status
}
return nfs4.ErrOK
case "DELE":
return h.delegs().checkDataStateid(st, fh, clientid)
default:
_, status := h.openStates().checkStateid(st, fh, clientid)
return status
}
}
// seekOp serves SEEK: the next hole or data byte from the offset.
func (h *Handler) seekOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
what, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if what != nfs4.ContentData && what != nfs4.ContentHole {
return nil, nfs4.ErrInval, nil
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
s := h.seeker()
if s == nil {
return nil, nfs4.ErrNotSupp, nil
}
seek := s.SeekData
if what == nfs4.ContentHole {
seek = s.SeekHole
}
if offset > 1<<62 {
return nil, nfs4.ErrNXIO, nil
}
found, eof, serr := seek(reg.cur, int64(offset))
if serr == nfsfs.ErrBeyondEOF {
return nil, nfs4.ErrNXIO, nil
}
if serr != nil {
return nil, mapErr(serr), nil
}
return nfs4.AppendSeekRes(nil, eof, uint64(found)), nfs4.ErrOK, nil
}
// rangeOp serves ALLOCATE and DEALLOCATE through the backend's space
// reservation half.
func (h *Handler) rangeOp(d *xdr.Decoder, reg *fhreg, deallocate bool) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
length, err := d.Uint64()
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
a := h.allocator()
if a == nil {
return nil, nfs4.ErrNotSupp, nil
}
if length == 0 {
return nil, nfs4.ErrInval, nil
}
if deallocate {
err = a.Deallocate(reg.cur, int64(offset), int64(length))
} else {
err = a.Allocate(reg.cur, int64(offset), int64(length))
}
if err != nil {
return nil, mapErr(err), nil
}
return nil, nfs4.ErrOK, nil
}
// ioAdviseOp serves IO_ADVISE: the hints ride through, no state kept.
func (h *Handler) ioAdviseOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // offset
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // count
return nil, 0, err
}
hints, err := nfs4.ReadBitmap(d)
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
return nfs4.AppendIoAdviseRes(nil, hints), nfs4.ErrOK, nil
}
// copyOp serves COPY between two files of this server: the saved handle
// is the source, the current one the destination, the copy runs
// synchronously in this compound.
func (h *Handler) copyOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
src, err := readStateid(d)
if err != nil {
return nil, 0, err
}
dst, err := readStateid(d)
if err != nil {
return nil, 0, err
}
srcOff, err := d.Uint64()
if err != nil {
return nil, 0, err
}
dstOff, err := d.Uint64()
if err != nil {
return nil, 0, err
}
count, err := d.Uint64()
if err != nil {
return nil, 0, err
}
consecutive, err := d.Bool()
if err != nil {
return nil, 0, err
}
synchronous, err := d.Bool()
if err != nil {
return nil, 0, err
}
sources, err := readNetlocList(d)
if err != nil {
return nil, 0, err
}
_ = sources
if !reg.haveCur || reg.saved == nil {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(src, reg.saved, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
if status := h.checkOpStateid(dst, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
if count > nfs4.DefaultLimits.MaxRead {
return nil, nfs4.ErrTooSmall, nil
}
// The kernel path moves the bytes without userspace touching them
// where the backend and the filesystem provide it; anywhere else the
// userspace loop carries the copy, the answer being the same.
var moved uint64
if cl, ok := h.FS.(nfsfs.RangeCloner); ok {
if err := cl.CopyRange(reg.saved, int64(srcOff), reg.cur, int64(dstOff), int64(count)); err == nil {
moved = count
}
}
if moved == 0 {
data, rerr := h.FS.Read(reg.saved, int64(srcOff), int(count))
if rerr != nil {
return nil, mapErr(rerr), nil
}
w := h.writer()
if w == nil {
return nil, nfs4.ErrROFS, nil
}
n, werr := w.Write(reg.cur, int64(dstOff), data)
if werr != nil {
return nil, mapErr(werr), nil
}
moved = uint64(n)
}
return nfs4.AppendCopyRes(nil, nfs4.Stateid{}, false, moved,
nfs4.NfsSyncFileSync, h.writeVerifier(), consecutive, synchronous), nfs4.ErrOK, nil
}
// readNetlocList decodes the netloc4 list the copy family carries.
func readNetlocList(d *xdr.Decoder) ([]nfs4.CopySourceServer, error) {
n, err := d.Uint32()
if err != nil {
return nil, err
}
var out []nfs4.CopySourceServer
for range n {
var s nfs4.CopySourceServer
if s.Type, err = d.Uint32(); err != nil {
return nil, err
}
switch s.Type {
case 1, 2:
if s.Name, err = d.String(); err != nil {
return nil, err
}
case 3:
if s.Addr.Netid, err = d.String(); err != nil {
return nil, err
}
if s.Addr.Uaddr, err = d.String(); err != nil {
return nil, err
}
default:
return nil, errors.New("nfs4server: unknown netloc type")
}
out = append(out, s)
}
return out, nil
}
// copyNotifyOp serves COPY_NOTIFY: the source server grants the copy to
// the named destination.
func (h *Handler) copyNotifyOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
dstType, err := d.Uint32()
if err != nil {
return nil, 0, err
}
var dst nfs4.CopySourceServer
dst.Type = dstType
switch dstType {
case 1, 2:
if dst.Name, err = d.String(); err != nil {
return nil, 0, err
}
case 3:
if dst.Addr.Netid, err = d.String(); err != nil {
return nil, 0, err
}
if dst.Addr.Uaddr, err = d.String(); err != nil {
return nil, 0, err
}
default:
return nil, nfs4.ErrInval, nil
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
setStateidSeq(&st, 1)
copy(st[4:], "CPYN")
return nfs4.AppendCopyNotifyRes(nil, int64(h.leasePeriod().Seconds()), st,
[]nfs4.CopySourceServer{{Type: 3, Addr: nfs4.NetAddr{Netid: "tcp", Uaddr: h.deviceAddr(nil)}}}), nfs4.ErrOK, nil
}
// offloadCancelOp serves OFFLOAD_CANCEL: nothing async is in flight in
// this build, so the stateid is checked and the call answered.
func (h *Handler) offloadCancelOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
return nil, nfs4.ErrOK, nil
}
// offloadStatusOp serves OFFLOAD_STATUS: no asynchronous copy was ever
// requested, which the standard answers OFFLOAD_NO_REQS for.
func (h *Handler) offloadStatusOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
if _, err := readStateid(d); err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
return nil, nfs4.ErrOffloadNoReqs, nil
}
// cloneOp serves CLONE between two files of this server: the saved
// handle is the source, the current one the destination.
func (h *Handler) cloneOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
src, err := readStateid(d)
if err != nil {
return nil, 0, err
}
dst, err := readStateid(d)
if err != nil {
return nil, 0, err
}
srcOff, err := d.Uint64()
if err != nil {
return nil, 0, err
}
dstOff, err := d.Uint64()
if err != nil {
return nil, 0, err
}
count, err := d.Uint64()
if err != nil {
return nil, 0, err
}
if !reg.haveCur || reg.saved == nil {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(src, reg.saved, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
if status := h.checkOpStateid(dst, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
// The offsets are signed on the wire in effect: a value beyond the
// signed range cannot name a byte of any file this server serves,
// and a count beyond the read limit would size one allocation from
// the request. Both are refused before anything is read.
if srcOff > 1<<62 || dstOff > 1<<62 || count > nfs4.DefaultLimits.MaxRead {
return nil, nfs4.ErrInval, nil
}
srcInfo, gerr := h.FS.Getattr(reg.saved)
if gerr != nil {
return nil, mapErr(gerr), nil
}
dstInfo, gerr := h.FS.Getattr(reg.cur)
if gerr != nil {
return nil, mapErr(gerr), nil
}
// The ends are checked without addition, so a wrapped sum can never
// slip past the size guard.
if uint64(srcInfo.Size) < srcOff || uint64(srcInfo.Size)-srcOff < count ||
uint64(dstInfo.Size) < dstOff || uint64(dstInfo.Size)-dstOff < count {
return nil, nfs4.ErrInval, nil
}
// CLONE is the reflink of the NFSv4.2 world: where the filesystem
// provides it the kernel shares the bytes, and where it does not the
// userspace copy answers instead, the same result either way.
if cl, ok := h.FS.(nfsfs.RangeCloner); ok {
if err := cl.CloneRange(reg.saved, int64(srcOff), reg.cur, int64(dstOff), int64(count)); err == nil {
return nil, nfs4.ErrOK, nil
}
}
data, rerr := h.FS.Read(reg.saved, int64(srcOff), int(count))
if rerr != nil {
return nil, mapErr(rerr), nil
}
w := h.writer()
if w == nil {
return nil, nfs4.ErrROFS, nil
}
if _, werr := w.Write(reg.cur, int64(dstOff), data); werr != nil {
return nil, mapErr(werr), nil
}
return nil, nfs4.ErrOK, nil
}
// layoutErrorOp serves LAYOUTERROR: the report is recorded and answered.
func (h *Handler) layoutErrorOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
if _, err := d.Uint64(); err != nil { // offset
return nil, 0, err
}
if _, err := d.Uint64(); err != nil { // length
return nil, 0, err
}
if _, err := readStateid(d); err != nil {
return nil, 0, err
}
n, err := d.Uint32()
if err != nil {
return nil, 0, err
}
for range n {
if _, err = d.Raw(16); err != nil { // device id
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // status
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // opnum
return nil, 0, err
}
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
return nil, nfs4.ErrOK, nil
}
// layoutStatsOp serves LAYOUTSTATS: the counters are accepted and the
// call answered.
func (h *Handler) layoutStatsOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
for range 2 {
if _, err := d.Uint64(); err != nil { // offset, length
return nil, 0, err
}
}
if _, err := readStateid(d); err != nil {
return nil, 0, err
}
for range 4 {
if _, err := d.Uint64(); err != nil { // io_info4 pairs
return nil, 0, err
}
}
if _, err := d.Raw(16); err != nil { // device id
return nil, 0, err
}
if _, err := d.Uint32(); err != nil { // layout update type
return nil, 0, err
}
if _, err := d.VarOpaque(); err != nil { // layout update body
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
return nil, nfs4.ErrOK, nil
}
// readPlusOp serves READ_PLUS: this backend has no sparse knowledge on
// the read path, so the answer is one data segment.
func (h *Handler) readPlusOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
count, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
if uint64(count) > nfs4.DefaultLimits.MaxRead {
count = uint32(nfs4.DefaultLimits.MaxRead)
}
data, rerr := h.FS.Read(reg.cur, int64(offset), int(count))
if rerr != nil {
return nil, mapErr(rerr), nil
}
eof := false
if info, gerr := h.FS.Getattr(reg.cur); gerr == nil {
eof = int64(offset)+int64(len(data)) >= info.Size
}
return nfs4.AppendReadPlusDataRes(nil, eof, offset, data), nfs4.ErrOK, nil
}
// writeSameOp serves WRITE_SAME: the application data block pattern is
// repeated over the block count at the offset.
func (h *Handler) writeSameOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
st, err := readStateid(d)
if err != nil {
return nil, 0, err
}
stable, err := d.Uint32()
if err != nil {
return nil, 0, err
}
offset, err := d.Uint64()
if err != nil {
return nil, 0, err
}
blockSize, err := d.Uint64()
if err != nil {
return nil, 0, err
}
blockCount, err := d.Uint64()
if err != nil {
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // adb_reloff_blocknum
return nil, 0, err
}
if _, err = d.Uint32(); err != nil { // adb_block_num, count4
return nil, 0, err
}
if _, err = d.Uint64(); err != nil { // adb_reloff_pattern
return nil, 0, err
}
pattern, err := d.VarOpaque()
if err != nil {
return nil, 0, err
}
_ = stable
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
if status := h.checkOpStateid(st, reg.cur, reg.clientID); status != nfs4.ErrOK {
return nil, status, nil
}
// The block size and the total the operation may write are bounded
// before anything is allocated: a wire controlled size beyond the
// write limit is refused, never used as an allocation length.
if blockSize == 0 || blockCount == 0 || len(pattern) == 0 {
return nil, nfs4.ErrInval, nil
}
if blockSize > nfs4.DefaultLimits.MaxWrite || blockCount > 1<<20 ||
blockSize*blockCount > nfs4.DefaultLimits.MaxWrite {
return nil, nfs4.ErrInval, nil
}
w := h.writer()
if w == nil {
return nil, nfs4.ErrROFS, nil
}
block := make([]byte, 0, blockSize)
for len(block) < int(blockSize) {
block = append(block, pattern...)
}
block = block[:blockSize]
total := int64(0)
for range blockCount {
if _, err := w.Write(reg.cur, int64(offset)+total, block); err != nil {
return nil, mapErr(err), nil
}
total += int64(blockSize)
}
return nfs4.AppendWriteSameRes(nil, uint64(total), nfs4.NfsSyncFileSync, h.writeVerifier()), nfs4.ErrOK, nil
}
+383
View File
@@ -0,0 +1,383 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestOps42Matrix(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "ops42", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
// Open the source file and create the copy targets.
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x5555, []byte("c42"), nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
})
wantStatus(t, "open src", res.Status, nfs4.ErrOK)
srcSt := bodyStateid(bodies[1])
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x5555, []byte("c42"), nfs4.ShareAccessBoth, 0, true, 0o644, "copy1"),
nfs4.AppendGetfh(nil),
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x5555, []byte("c42"), nfs4.ShareAccessBoth, 0, true, 0o644, "clone1"),
nfs4.AppendGetfh(nil),
})
wantStatus(t, "open targets", res.Status, nfs4.ErrOK)
copySt := bodyStateid(bodies[1])
copyFH, ferr := xdr.NewDecoder(bodies[2]).VarOpaque()
if ferr != nil {
t.Fatal(ferr)
}
cloneSt := bodyStateid(bodies[4])
cloneFH, ferr := xdr.NewDecoder(bodies[5]).VarOpaque()
if ferr != nil {
t.Fatal(ferr)
}
// COPY: saved fh is the source, current fh the destination.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutfh(nil, copyFH),
nfs4.AppendCopyArgs(nil, srcSt, copySt, 0, 0, 6, true, true, nil),
})
wantStatus(t, "copy", res.Status, nfs4.ErrOK)
cd := xdr.NewDecoder(bodies[4])
if n, cerr := cd.Uint32(); cerr != nil || n != 0 {
t.Fatalf("callback array %d: %v", n, cerr)
}
if count, cerr := cd.Uint64(); cerr != nil || count != 6 {
t.Fatalf("copied %d: %v", count, cerr)
}
data, rerr := h.FS.Read(copyFH, 0, 32)
if rerr != nil || !bytes.Equal(data, []byte("hello ")) {
t.Fatalf("copy content %q: %v", data, rerr)
}
// CLONE: the whole source into the target, ranges must fit.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutfh(nil, cloneFH),
nfs4.AppendCloneArgs(nil, srcSt, cloneSt, 0, 0, 1000),
})
if res.Status != nfs4.ErrInval {
t.Fatalf("clone beyond size: status %d", res.Status)
}
// The clone target must be pre-extended to the range size, RFC 7862
// section 15.1.4.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, cloneFH),
nfs4.AppendSetattrArgs(nil, nfs4.Stateid{}, nfs4.OfBits(nfs4.AttrSize), nfs4.Attrs{Size: 9}),
})
wantStatus(t, "extend clone target", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutfh(nil, cloneFH),
nfs4.AppendCloneArgs(nil, srcSt, cloneSt, 0, 0, 9),
})
wantStatus(t, "clone", res.Status, nfs4.ErrOK)
data, rerr = h.FS.Read(cloneFH, 0, 32)
if rerr != nil || !bytes.Equal(data, []byte("hello nfs")) {
t.Fatalf("clone content %q: %v", data, rerr)
}
// READ_PLUS answers one data segment.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendReadPlusArgs(nil, srcSt, 0, 32),
})
wantStatus(t, "read plus", res.Status, nfs4.ErrOK)
rd := xdr.NewDecoder(bodies[2])
if eof, eerr := rd.Bool(); eerr != nil || !eof {
t.Fatalf("eof %v: %v", eof, eerr)
}
if n, _ := rd.Uint32(); n != 1 {
t.Fatalf("contents %d", n)
}
if kind, _ := rd.Uint32(); kind != nfs4.ContentData {
t.Fatalf("content kind %d", kind)
}
if _, err := rd.Uint64(); err != nil {
t.Fatal(err)
}
pdata, perr := rd.VarOpaque()
if perr != nil || !bytes.Equal(pdata, []byte("hello nfs")) {
t.Fatalf("read plus data %q: %v", pdata, perr)
}
// IO_ADVISE echoes the hints; WRITE_SAME repeats the pattern.
hints := nfs4.Bitmap{}.With(1)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "copy1"),
nfs4.AppendIoAdviseArgs(nil, copySt, 0, 100, hints),
})
wantStatus(t, "io advise", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "copy1"),
nfs4.AppendWriteSameArgs(nil, copySt, nfs4.StableFileSync, 0, 4, 3, 0, 0, 0, []byte("ab")),
})
wantStatus(t, "write same", res.Status, nfs4.ErrOK)
data, rerr = h.FS.Read(copyFH, 0, 12)
if rerr != nil || !bytes.Equal(data, []byte("abababababab")) {
t.Fatalf("write same content %q: %v", data, rerr)
}
// OFFLOAD_STATUS without a request answers the dedicated status.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "copy1"),
nfs4.AppendOffloadStatusArgs(nil, copySt),
})
wantStatus(t, "offload status", res.Status, nfs4.ErrOffloadNoReqs)
// OFFLOAD_CANCEL answers OK.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "copy1"),
nfs4.AppendOffloadCancelArgs(nil, copySt),
})
wantStatus(t, "offload cancel", res.Status, nfs4.ErrOK)
// COPY_NOTIFY grants the copy with this server as the source.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendCopyNotifyArgs(nil, srcSt, 3, "", nfs4.NetAddr{Netid: "tcp", Uaddr: "10.0.0.9.8.1"}),
})
wantStatus(t, "copy notify", res.Status, nfs4.ErrOK)
// LAYOUTERROR and LAYOUTSTATS accept the report.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLayoutErrorArgs(nil, 0, 4096, srcSt, layoutDeviceID, nfs4.ErrBadStateid, nfs4.OpRead),
})
wantStatus(t, "layout error", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLayoutStatsArgs(nil, 0, 4096, srcSt, 1, 9, 0, 0, layoutDeviceID, 0, nil),
})
wantStatus(t, "layout stats", res.Status, nfs4.ErrOK)
// SEEK: the backend has no hole in a.txt; a data seek finds offset 0
// and a hole seek past the end answers NOSPC.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, srcSt, 3, nfs4.ContentData),
})
wantStatus(t, "seek data", res.Status, nfs4.ErrOK)
sd := xdr.NewDecoder(bodies[2])
if eof, _ := sd.Bool(); eof {
t.Fatal("seek data reported eof")
}
if off, _ := sd.Uint64(); off != 3 {
t.Fatalf("seek offset %d", off)
}
}
func TestOps42RangesAndEdges(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "rng", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x6666, []byte("rng"), nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
st := bodyStateid(bodies[1])
// ALLOCATE reserves, DEALLOCATE releases; a zero length is refused.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendAllocateArgs(nil, st, 0, 64),
})
wantStatus(t, "allocate", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendDeallocateArgs(nil, st, 0, 64),
})
wantStatus(t, "deallocate", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendDeallocateArgs(nil, st, 0, 0),
})
wantStatus(t, "deallocate zero", res.Status, nfs4.ErrInval)
// SEEK with a nonsense content kind answers INVAL.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, st, 0, 9),
})
wantStatus(t, "seek nonsense", res.Status, nfs4.ErrInval)
// COPY with named source servers decodes the netloc list.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutfh(nil, mustLookup(t, h, "a.txt")),
nfs4.AppendCopyArgs(nil, st, nfs4.Stateid{}, 0, 0, 0, false, true,
[]nfs4.CopySourceServer{{Type: 1, Name: "src.example.org"}}),
})
wantStatus(t, "copy zero length", res.Status, nfs4.ErrOK)
// REMOVEXATTR of a missing attribute answers NOXATTR.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendRemoveXattrArgs(nil, "user.nothere"),
})
wantStatus(t, "remove missing xattr", res.Status, nfs4.ErrNoXattr)
}
func TestOps42WithoutFileHandle(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
// Every 4.2 data operation refuses to run without a current file
// handle; a table keeps the coverage of the guard honest.
table := []struct {
name string
op []byte
}{
{"seek", nfs4.AppendSeekArgs(nil, nfs4.Stateid{}, 0, nfs4.ContentData)},
{"allocate", nfs4.AppendAllocateArgs(nil, nfs4.Stateid{}, 0, 8)},
{"deallocate", nfs4.AppendDeallocateArgs(nil, nfs4.Stateid{}, 0, 8)},
{"ioadvise", nfs4.AppendIoAdviseArgs(nil, nfs4.Stateid{}, 0, 8, nfs4.Bitmap{})},
{"copy", nfs4.AppendCopyArgs(nil, nfs4.Stateid{}, nfs4.Stateid{}, 0, 0, 0, false, true, nil)},
{"copynotify", nfs4.AppendCopyNotifyArgs(nil, nfs4.Stateid{}, 2, "dst", nfs4.NetAddr{})},
{"offloadcancel", nfs4.AppendOffloadCancelArgs(nil, nfs4.Stateid{})},
{"offloadstatus", nfs4.AppendOffloadStatusArgs(nil, nfs4.Stateid{})},
{"layouterror", nfs4.AppendLayoutErrorArgs(nil, 0, 0, nfs4.Stateid{}, layoutDeviceID, 0, 0)},
{"layoutstats", nfs4.AppendLayoutStatsArgs(nil, 0, 0, nfs4.Stateid{}, 0, 0, 0, 0, layoutDeviceID, 0, nil)},
{"readplus", nfs4.AppendReadPlusArgs(nil, nfs4.Stateid{}, 0, 8)},
{"writesame", nfs4.AppendWriteSameArgs(nil, nfs4.Stateid{}, nfs4.StableFileSync, 0, 8, 1, 0, 0, 0, []byte("x"))},
}
for _, row := range table {
seq++
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, row.op)
payload := nfs4.AppendCompoundArgs(nil, "no-fh", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatalf("%s: garbage", row.name)
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatalf("%s: decode %v", row.name, err)
}
if res.Status != nfs4.ErrNoFileHandle {
t.Fatalf("%s: status %d, want NOFILE_HANDLE", row.name, res.Status)
}
}
}
func TestSeekUnderLockStateid(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "lockseek", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x7777, []byte("ls"), nfs4.ShareAccessBoth, 0, false, 0, "a.txt"),
})
wantStatus(t, "open", res.Status, nfs4.ErrOK)
openSt := bodyStateid(bodies[1])
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendLockArgsNew(nil, openSt, 0x7777, []byte("locker"),
nfs4.LockTypeWrite, false, 0, 9),
})
wantStatus(t, "lock", res.Status, nfs4.ErrOK)
lockSt := bodyStateid(bodies[2])
// A data operation under the LOCK stateid is legal; the store falls
// back from the open store to the lock store.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSeekArgs(nil, lockSt, 0, nfs4.ContentData),
})
wantStatus(t, "seek under lock", res.Status, nfs4.ErrOK)
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"log"
"strconv"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
)
// opNames names the operations the log answers with, keyed by their wire
// number. An operation outside the table logs as its number.
var opNames = map[uint32]string{
nfs4.OpAccess: "ACCESS", nfs4.OpClose: "CLOSE", nfs4.OpCommit: "COMMIT",
nfs4.OpCreate: "CREATE", nfs4.OpDelegPurge: "DELEGPURGE",
nfs4.OpDelegReturn: "DELEGRETURN", nfs4.OpGetattr: "GETATTR",
nfs4.OpGetfh: "GETFH", nfs4.OpLink: "LINK", nfs4.OpLock: "LOCK",
nfs4.OpLockt: "LOCKT", nfs4.OpLocku: "LOCKU", nfs4.OpLookup: "LOOKUP",
nfs4.OpLookupp: "LOOKUPP", nfs4.OpNverify: "NVERIFY", nfs4.OpOpen: "OPEN",
nfs4.OpOpenattr: "OPENATTR", nfs4.OpOpenConfirm: "OPEN_CONFIRM",
nfs4.OpOpenDowngrade: "OPEN_DOWNGRADE", nfs4.OpPutfh: "PUTFH",
nfs4.OpPutPubfh: "PUTPUBFH", nfs4.OpPutRootfh: "PUTROOTFH",
nfs4.OpRead: "READ", nfs4.OpReaddir: "READDIR", nfs4.OpReadlink: "READLINK",
nfs4.OpRemove: "REMOVE", nfs4.OpRename: "RENAME", nfs4.OpRenew: "RENEW",
nfs4.OpRestorefh: "RESTOREFH", nfs4.OpSavefh: "SAVEFH",
nfs4.OpSecinfo: "SECINFO", nfs4.OpSetattr: "SETATTR",
nfs4.OpSetClientID: "SETCLIENTID", nfs4.OpSetClientIDConfirm: "SETCLIENTID_CONFIRM",
nfs4.OpVerify: "VERIFY", nfs4.OpWrite: "WRITE",
nfs4.OpReleaseLockOwner: "RELEASE_LOCKOWNER",
nfs4.OpBackchannelCtl: "BACKCHANNEL_CTL",
nfs4.OpBindConnToSession: "BIND_CONN_TO_SESSION",
nfs4.OpExchangeID: "EXCHANGE_ID", nfs4.OpCreateSession: "CREATE_SESSION",
nfs4.OpDestroySession: "DESTROY_SESSION", nfs4.OpFreeStateid: "FREE_STATEID",
nfs4.OpGetDirDelegation: "GET_DIR_DELEGATION",
nfs4.OpGetDeviceInfo: "GETDEVICEINFO", nfs4.OpGetDeviceList: "GETDEVICELIST",
nfs4.OpLayoutCommit: "LAYOUTCOMMIT", nfs4.OpLayoutGet: "LAYOUTGET",
nfs4.OpLayoutReturn: "LAYOUTRETURN", nfs4.OpSecinfoNoName: "SECINFO_NO_NAME",
nfs4.OpSequence: "SEQUENCE", nfs4.OpSetSsv: "SET_SSV",
nfs4.OpTestStateid: "TEST_STATEID", nfs4.OpWantDelegation: "WANT_DELEGATION",
nfs4.OpDestroyClientID: "DESTROY_CLIENTID",
nfs4.OpReclaimComplete: "RECLAIM_COMPLETE", nfs4.OpAllocate: "ALLOCATE",
nfs4.OpCopy: "COPY", nfs4.OpCopyNotify: "COPY_NOTIFY",
nfs4.OpDeallocate: "DEALLOCATE", nfs4.OpIoAdvise: "IO_ADVISE",
nfs4.OpLayoutError: "LAYOUTERROR", nfs4.OpLayoutStats: "LAYOUTSTATS",
nfs4.OpOffloadCancel: "OFFLOAD_CANCEL", nfs4.OpOffloadStatus: "OFFLOAD_STATUS",
nfs4.OpReadPlus: "READ_PLUS", nfs4.OpSeek: "SEEK", nfs4.OpWriteSame: "WRITE_SAME",
nfs4.OpClone: "CLONE", nfs4.OpGetxattr: "GETXATTR", nfs4.OpSetxattr: "SETXATTR",
nfs4.OpListxattr: "LISTXATTR", nfs4.OpRemovexattr: "REMOVEXATTR",
}
// opName answers the name of one operation for the log.
func opName(op uint32) string {
if name, ok := opNames[op]; ok {
return name
}
return "OP" + strconv.FormatUint(uint64(op), 10)
}
// logOp answers one log line per operation when -log-ops is on: the
// operation, the status it answered and the time it took, on stderr.
func logOp(op uint32, status uint32, took time.Duration) {
log.Printf("nfs: %s status %d %s", opName(op), status, took)
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"log"
"os"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
)
// TestOpName covers the log names of the common operations and the
// numeric fallback.
func TestOpName(t *testing.T) {
for op, want := range map[uint32]string{
nfs4.OpGetattr: "GETATTR", nfs4.OpReaddir: "READDIR",
nfs4.OpReclaimComplete: "RECLAIM_COMPLETE", nfs4.OpGetxattr: "GETXATTR",
999: "OP999",
} {
if got := opName(op); got != want {
t.Fatalf("opName(%d) = %q, want %q", op, got, want)
}
}
}
// TestLogOp covers the log line shape: operation, status, duration.
func TestLogOp(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
logOp(nfs4.OpWrite, nfs4.ErrOK, 1500*time.Microsecond)
line := buf.String()
if !strings.Contains(line, "WRITE status 0") || !strings.Contains(line, "1.5") {
t.Fatalf("log line %q misses the operation, the status or the duration", line)
}
}
+327
View File
@@ -0,0 +1,327 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"bytes"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// layoutStateidOf pulls the layout stateid out of a LAYOUTGET response
// body and checks the response shape.
func layoutStateidOf(t *testing.T, body []byte) nfs4.Stateid {
t.Helper()
d := xdr.NewDecoder(body)
var st nfs4.Stateid
raw, err := d.Raw(16)
if err != nil {
t.Fatalf("layout stateid: %v", err)
}
copy(st[:], raw)
roc, err := d.Bool()
if err != nil || roc {
t.Fatalf("return on close %v: %v", roc, err)
}
n, err := d.Uint32()
if err != nil || n != 1 {
t.Fatalf("layout count %d: %v", n, err)
}
if _, err = d.Uint64(); err != nil { // offset
t.Fatal(err)
}
if _, err = d.Uint64(); err != nil { // length
t.Fatal(err)
}
if mode, err := d.Uint32(); err != nil || mode != nfs4.IoModeRW {
t.Fatalf("iomode %d: %v", mode, err)
}
if typ, err := d.Uint32(); err != nil || typ != nfs4.LayoutTypeFlexfiles {
t.Fatalf("type %d: %v", typ, err)
}
flex, err := d.VarOpaque()
if err != nil {
t.Fatal(err)
}
fd := xdr.NewDecoder(flex)
if stripe, err := fd.Uint64(); err != nil || stripe != 0 {
t.Fatalf("stripe unit: %v", err)
}
if mirrors, err := fd.Uint32(); err != nil || mirrors != 1 {
t.Fatalf("mirrors %d: %v", mirrors, err)
}
if servers, err := fd.Uint32(); err != nil || servers != 1 {
t.Fatalf("data servers %d: %v", servers, err)
}
var device [16]byte
raw, rerr := fd.Raw(16)
if rerr != nil {
t.Fatal(rerr)
}
copy(device[:], raw)
if device != layoutDeviceID {
t.Fatalf("device id % x", device[:])
}
if _, err = fd.Uint32(); err != nil { // efficiency
t.Fatal(err)
}
raw, rerr = fd.Raw(16)
if rerr != nil {
t.Fatal(rerr)
}
if !bytes.Equal(raw, st[:]) {
t.Fatalf("data server stateid % x, want % x", raw, st[:])
}
fhCount, ferr := fd.Uint32()
if ferr != nil || fhCount != 1 {
t.Fatalf("file handle count %d: %v", fhCount, ferr)
}
if _, err = fd.VarOpaque(); err != nil { // the data server file handle
t.Fatal(err)
}
return st
}
func TestPnfsLayoutLifecycle(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "pnfs", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
if len(r.Ops) != len(all) {
t.Fatalf("ops %d, want %d", len(r.Ops), len(all))
}
return r, bodies[1:]
}
// Open (create) the file the layout will hang from.
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x2222, []byte("pnfs-owner"),
nfs4.ShareAccessBoth, 0, true, 0o644, "lay.txt"),
nfs4.AppendGetfh(nil),
})
if res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d", res.Status)
}
var openSt nfs4.Stateid
copy(openSt[:], bodies[1])
fh, err := xdr.NewDecoder(bodies[2]).VarOpaque()
if err != nil || len(fh) == 0 {
t.Fatalf("file handle: %v", err)
}
// LAYOUTGET grants one flexfiles segment naming this server as the
// data server.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "layout get", res.Status, nfs4.ErrOK)
layoutSt := layoutStateidOf(t, bodies[1])
if !bytes.HasPrefix(layoutSt[4:], []byte("LAYOUT")) {
t.Fatalf("layout stateid other % x", layoutSt[4:])
}
if h.layouts().count() != 1 {
t.Fatalf("layouts %d, want 1", h.layouts().count())
}
// GETDEVICEINFO refuses an unknown device and answers the address of
// the one device this server carries.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendGetDeviceInfoArgs(nil, [16]byte{7}, nfs4.LayoutTypeFlexfiles, 1<<16, nfs4.Bitmap{}),
})
wantStatus(t, "unknown device", res.Status, nfs4.ErrNoEnt)
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendGetDeviceInfoArgs(nil, layoutDeviceID, nfs4.LayoutTypeFlexfiles, 1<<16, nfs4.Bitmap{}),
})
wantStatus(t, "device info", res.Status, nfs4.ErrOK)
d := xdr.NewDecoder(bodies[1])
if typ, derr := d.Uint32(); derr != nil || typ != nfs4.LayoutTypeFlexfiles {
t.Fatalf("address type %d: %v", typ, derr)
}
blob, derr := d.VarOpaque()
if derr != nil {
t.Fatal(derr)
}
addr, derr := nfs4.DecodeFlexDeviceAddr(blob)
if derr != nil {
t.Fatalf("decode address: %v", derr)
}
if len(addr.NetAddrs) != 1 || addr.NetAddrs[0].Netid != "tcp" || addr.NetAddrs[0].Uaddr == "" {
t.Fatalf("net addresses %+v", addr.NetAddrs)
}
if len(addr.Versions) != 1 || addr.Versions[0].Version != 4 || addr.Versions[0].MinorVersion != nfs4.MinorVersion {
t.Fatalf("versions %+v", addr.Versions)
}
// LAYOUTCOMMIT with a last write beyond the size grows the file.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutCommitArgs(nil, 0, ^uint64(0), false, layoutSt, true, 100, false, 0, 0, 0, nil),
})
wantStatus(t, "layout commit", res.Status, nfs4.ErrOK)
cd := xdr.NewDecoder(bodies[1])
if changed, cerr := cd.Bool(); cerr != nil || !changed {
t.Fatalf("size changed %v: %v", changed, cerr)
}
if size, cerr := cd.Uint64(); cerr != nil || size != 101 {
t.Fatalf("new size %d: %v", size, cerr)
}
info, gerr := h.FS.Getattr(fh)
if gerr != nil || info.Size != 101 {
t.Fatalf("backend size %d: %v", info.Size, gerr)
}
// A layout get with an anonymous stateid has no open behind it.
allOnes := nfs4.Stateid{}
for i := range allOnes {
allOnes[i] = 0xff
}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, 0, ^uint64(0), 0, allOnes, 1<<16),
})
wantStatus(t, "layout get without open", res.Status, nfs4.ErrBadStateid)
// An unknown layout type and a bogus IO mode are refused before any
// state is touched. The block layout is one of the emulated families,
// so the probe uses a type nothing supports.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, 99,
nfs4.IoModeRW, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "unknown layout type", res.Status, nfs4.ErrUnknownLayoutType)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexfiles,
0, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "bad iomode", res.Status, nfs4.ErrBadIOMode)
// LAYOUTRETURN of the file drops the layout; the commit under the
// returned stateid is refused afterwards.
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutReturnFileArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, 0, ^uint64(0), layoutSt),
})
wantStatus(t, "layout return", res.Status, nfs4.ErrOK)
rd := xdr.NewDecoder(bodies[1])
if returned, rerr := rd.Bool(); rerr != nil || !returned {
t.Fatalf("returned %v: %v", returned, rerr)
}
if h.layouts().count() != 0 {
t.Fatalf("layouts %d after return, want 0", h.layouts().count())
}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutCommitArgs(nil, 0, ^uint64(0), false, layoutSt, false, 0, false, 0, 0, 0, nil),
})
wantStatus(t, "layout commit after return", res.Status, nfs4.ErrBadStateid)
// A whole client return drops every layout of the session.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "layout get again", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutReturnArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, nfs4.ReturnAll),
})
wantStatus(t, "layout return all", res.Status, nfs4.ErrOK)
if h.layouts().count() != 0 {
t.Fatalf("layouts %d after whole client return, want 0", h.layouts().count())
}
// DESTROY_SESSION drops the layouts the session still holds.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendLayoutGetArgs(nil, false, nfs4.LayoutTypeFlexfiles,
nfs4.IoModeRW, 0, ^uint64(0), 0, openSt, 1<<16),
})
wantStatus(t, "layout get before destroy", res.Status, nfs4.ErrOK)
if h.layouts().count() != 1 {
t.Fatalf("layouts %d, want 1", h.layouts().count())
}
payload := nfs4.AppendCompoundArgs(nil, "pnfs", nfs4.MinorVersion,
[][]byte{nfs4.AppendDestroySessionArgs(nil, sid)})
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage destroy")
}
dres, _, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil || dres.Status != nfs4.ErrOK {
t.Fatalf("destroy session: status %d %v", dres.Status, derr)
}
if h.layouts().count() != 0 {
t.Fatalf("layouts %d after destroy, want 0", h.layouts().count())
}
}
func TestUaddrOf(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"127.0.0.1:2049", "127.0.0.1.8.1"},
{"192.168.1.5:65535", "192.168.1.5.255.255"},
{"0.0.0.0:1", "0.0.0.0.0.1"},
{"[::1]:2049", "00.00.00.00.00.00.00.00.00.00.00.00.00.00.00.01.8.1"},
{"no-host", ""},
{"host:notaport", ""},
{"host.example.com:80", ""},
{"1.2.3.4:99999", ""},
{"1.2.3.4:", ""},
}
for _, c := range cases {
if got := uaddrOf(c.in); got != c.want {
t.Errorf("uaddrOf(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestDeviceAddrPreference(t *testing.T) {
t.Parallel()
h := &Handler{DeviceAddr: "10.0.0.1.8.1"}
if got := h.deviceAddr(nil); got != "10.0.0.1.8.1" {
t.Fatalf("configured address ignored: %q", got)
}
h2 := &Handler{}
if got := h2.deviceAddr(nil); got != "127.0.0.1.8.1" {
t.Fatalf("loopback default: %q", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server_test
import (
"errors"
"net"
"os"
"path/filepath"
"syscall"
"testing"
"io/fs"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// serveSquash starts a handler over a loopback listener with the export
// holding one file, and answers whether root is squashed.
func serveSquash(t *testing.T, squash bool) (addr, file string) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "x.txt"), []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
handler := &nfs4server.Handler{FS: backend, RootSquash: squash}
go func() { _ = (&server.Server{Handle: handler.HandleConn}).Serve(t.Context(), ln) }()
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer cl.Close()
if err := cl.Establish("squash-test"); err != nil {
t.Fatalf("Establish: %v", err)
}
res, _, err := cl.Compound("remove", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendRemoveArgs(nil, "x.txt"),
})
if err != nil {
t.Fatalf("remove compound: %v", err)
}
// A removal inside the export root needs the modify right on the
// directory: the superuser grant answers it, the anonymous identity
// does not.
status := res.Status
if squash && status != nfs4.ErrAccess {
t.Fatalf("a squashed root removed a file it must not: status %d", status)
}
if !squash && status != nfs4.ErrOK {
t.Fatalf("root lost its grant without squash: status %d", status)
}
return ln.Addr().String(), filepath.Join(root, "x.txt")
}
// TestRootSquashRemovesRootGrant covers the core of the feature: the
// squashed credential has no superuser grant, so a removal root may not
// make is refused with NFS4ERR_ACCESS.
func TestRootSquashRemovesRootGrant(t *testing.T) {
serveSquash(t, true)
}
// TestNoSquashKeepsRootGrant covers the default: without the switch the
// root claim keeps everything AUTH_SYS grants it.
func TestNoSquashKeepsRootGrant(t *testing.T) {
addr, file := serveSquash(t, false)
if _, err := os.Stat(file); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("the removal did not land: %v", err)
}
_ = addr
}
// TestRootSquashOwnerUnderRoot covers the owner attribution on a server
// that can chown: a file root creates carries nobody. A server without
// the privilege keeps its own identity on the objects it makes, the
// documented fallback, so the assertion runs only where it is
// guaranteed.
func TestRootSquashOwnerUnderRoot(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("the owner attribution needs the chown privilege; run as root")
}
root := t.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer ln.Close()
handler := &nfs4server.Handler{FS: backend, RootSquash: true}
go func() { _ = (&server.Server{Handle: handler.HandleConn}).Serve(t.Context(), ln) }()
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer cl.Close()
if err := cl.Establish("squash-owner"); err != nil {
t.Fatalf("Establish: %v", err)
}
res, bodies, err := cl.Compound("create", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0, []byte("root"), nfs4.ShareAccessBoth, 0,
true, 0o644, "made.txt"),
nfs4.AppendGetfh(nil),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
var st nfs4.Stateid
copy(st[:], bodies[len(bodies)-2])
fh, err := xdr.NewDecoder(bodies[len(bodies)-1]).VarOpaque()
if err != nil {
t.Fatalf("decode fh: %v", err)
}
if _, _, err := cl.Compound("close", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendCloseArgs(nil, st),
}); err != nil {
t.Fatalf("close: %v", err)
}
info, err := os.Stat(filepath.Join(root, "made.txt"))
if err != nil {
t.Fatalf("stat: %v", err)
}
sys, ok := info.Sys().(*syscall.Stat_t)
if !ok {
t.Skip("the raw stat is unavailable on this platform")
}
if sys.Uid != 65534 {
t.Fatalf("a root created file carries uid %d, want 65534", sys.Uid)
}
}
+408
View File
@@ -0,0 +1,408 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"fmt"
"sync"
"sync/atomic"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
)
// A slot is one entry of the fore channel slot table: the sequence number
// the slot is at and the cached bytes of the last answer, which is what a
// retry after a lost reply replays.
type slot struct {
sequence uint32
status uint32 // the COMPOUND status the cached answer carries
cached []byte // the result ops of the cached answer
used bool
}
// A session is one CREATE_SESSION product: its identifier, its slot
// table, the callback program the client picked and the connection the
// back channel rides on.
type session struct {
id nfs4.SessionID
// slMu guards the slot table: slot sequences and their cached
// replies are the per session state, contended only by the
// compounds of the session that owns them.
slMu sync.Mutex
slots []slot
cb *connCB
cbProg uint32
cbMu sync.Mutex
cbSeq uint32
}
// A client is one EXCHANGE_ID identity: the verifier it rebooted with,
// the sessions it created and the CREATE_SESSION replay state.
type client struct {
id uint64
verifier [8]byte
sessions map[nfs4.SessionID]*session
lastCSSeq uint32
lastCSID nfs4.SessionID
csHas bool
// renewNS carries the last renewal as unix nanoseconds, read and
// written atomically: the lease check runs per operation and must
// not serialise the clients against each other.
renewNS atomic.Int64
}
// sessionStore keeps the clients and sessions of the server. Every method
// is safe for concurrent use.
type sessionStore struct {
mu sync.RWMutex
prefix [4]byte
nextSess uint32
nextID uint64
byOwner map[string]*ownerEntry // the owner id is the client identity
byID map[uint64]*client // clientid, assigned by the server
sessions map[nfs4.SessionID]*session
}
// An ownerEntry pairs a client identity with the verifier of the life it
// was registered under.
type ownerEntry struct {
verifier [8]byte
client *client
}
func newSessionStore(prefix [4]byte) *sessionStore {
return &sessionStore{
prefix: prefix,
// Client ids start from a random base and count up: the ids stay
// unique, and a foreign client cannot walk another's id by
// guessing a small counter.
nextID: randCounter(),
byOwner: make(map[string]*ownerEntry),
byID: make(map[uint64]*client),
sessions: make(map[nfs4.SessionID]*session),
}
}
// exchangeID resolves the owner to a client id. The same verifier and
// owner id confirm the client it already assigned; a new verifier with a
// known owner id means the client rebooted and takes everything with it.
// The flags of the request are refused by design, RFC 8881 section 13.1:
// the reply carries the server's own roles, never an echo.
func (s *sessionStore) exchangeID(verifier [8]byte, ownerID []byte, now time.Time) (clientid uint64, sequence uint32, outFlags uint32, rebooted uint64) {
// The reply flags carry this server's own roles, RFC 8881 section
// 13.1: it never echoes the request. The server is a metadata server
// with itself as the data server, and it serves referrals. USE_PNFS
// MDS and USE_NON_PNFS are mutually exclusive roles and the Linux
// client rejects a reply that claims both; this server speaks pNFS,
// so it claims MDS and DS. CONFIRMED_R is added only when a session
// already exists; BIND_PRINC_STATEID and SUPP_FENCE_OPS stay off
// because this build binds no stateids to principals and fences
// nothing.
serverFlags := uint32(nfs4.ExchgIDUsePnfsMds | nfs4.ExchgIDUsePnfsDs |
nfs4.ExchgIDSuppMovedRefer)
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.byOwner[string(ownerID)]; ok {
if e.verifier == verifier {
// The same life of the same client: confirm it. The exchange
// itself proves liveness, so it renews the lease.
e.client.renewNS.Store(now.UnixNano())
return e.client.id, e.client.lastCSSeq, serverFlags | nfs4.ExchgIDConfirmedR, 0
}
// A new verifier for a known owner is a reboot: everything the
// client had is gone with the old life. The caller releases the
// old client's state everywhere; this store drops its sessions.
old := e.client.id
s.dropClient(e.client)
rebooted = old
}
s.nextID++
c := &client{id: s.nextID, verifier: verifier, sessions: make(map[nfs4.SessionID]*session)}
c.renewNS.Store(now.UnixNano())
s.byOwner[string(ownerID)] = &ownerEntry{verifier: verifier, client: c}
s.byID[c.id] = c
return c.id, 0, serverFlags, 0
}
// createSession makes a session for the client, or replays the cached
// answer when the sequence repeats. Every session carries a distinct
// id: four random bytes of the server prefix and a per-session number,
// so a second CREATE_SESSION never reuses the first session's id and
// its slot table, RFC 8881 section 18.36. The boolean reports a replay.
func (s *sessionStore) createSession(clientid uint64, sequence, cbProgram uint32) (id nfs4.SessionID, replay bool, status uint32) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.byID[clientid]
if !ok {
return id, false, nfs4.ErrStaleClientID
}
if c.csHas {
if sequence == c.lastCSSeq {
// A retry of the same CREATE_SESSION: answer with the id the
// first attempt minted.
return c.lastCSID, true, nfs4.ErrOK
}
if sequence < c.lastCSSeq {
return id, false, nfs4.ErrSeqMisordered
}
}
s.nextSess++
id = nfs4.MakeNumberedSessionID(s.prefix, s.nextSess, clientid)
sess := &session{id: id, slots: make([]slot, defaultSlots), cbProg: cbProgram}
c.sessions[id] = sess
c.lastCSSeq = sequence
c.lastCSID = id
c.csHas = true
c.renewNS.Store(time.Now().UnixNano())
s.sessions[id] = sess
return id, false, nfs4.ErrOK
}
// destroySession removes the session.
func (s *sessionStore) destroySession(id nfs4.SessionID) uint32 {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.sessions[id]; !ok {
return nfs4.ErrBadSession
}
delete(s.sessions, id)
if c, ok := s.byID[id.ClientIDOf()]; ok {
delete(c.sessions, id)
}
return nfs4.ErrOK
}
// sequence advances the slot. The reply cached on the slot answers a
// repeated sequence; a sequence that is neither the cached one nor the
// next one is misordered. The boolean reports a replay.
func (s *sessionStore) sequence(id nfs4.SessionID, sequence, slotID uint32) (*session, bool, uint32) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return nil, false, nfs4.ErrBadSession
}
if slotID >= uint32(len(sess.slots)) {
return nil, false, nfs4.ErrBadSlot
}
// The slot belongs to this session alone; other sessions of other
// clients proceed beside it.
sess.slMu.Lock()
defer sess.slMu.Unlock()
sl := &sess.slots[slotID]
switch {
case !sl.used:
sl.used = true
sl.sequence = sequence
return sess, false, nfs4.ErrOK
case sl.sequence == sequence:
return sess, true, nfs4.ErrOK
case sl.sequence+1 == sequence:
sl.sequence = sequence
return sess, false, nfs4.ErrOK
default:
return nil, false, nfs4.ErrSeqMisordered
}
}
// cacheReply stores the answer bytes of one slot for its replay.
func (s *sessionStore) cacheReply(id nfs4.SessionID, slotID uint32, status uint32, ops []byte) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || slotID >= uint32(len(sess.slots)) {
return
}
sess.slMu.Lock()
defer sess.slMu.Unlock()
sess.slots[slotID].status = status
sess.slots[slotID].cached = ops
}
// replay returns the cached answer of the slot.
func (s *sessionStore) replay(id nfs4.SessionID, slotID uint32) (status uint32, ops []byte, ok bool) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || slotID >= uint32(len(sess.slots)) {
return 0, nil, false
}
sess.slMu.Lock()
defer sess.slMu.Unlock()
sl := &sess.slots[slotID]
return sl.status, sl.cached, sl.used
}
// dropClient removes a client and every session it made.
func (s *sessionStore) dropClient(c *client) {
for id := range c.sessions {
delete(s.sessions, id)
}
delete(s.byID, c.id)
}
// defaultSlots is the fore channel slot table the server grants.
const defaultSlots = 8
// renew marks the client's lease as refreshed. A SEQUENCE from any session
// of the client renews it, as do the stateful operations.
func (s *sessionStore) renew(clientid uint64, now time.Time) {
s.mu.RLock()
c, ok := s.byID[clientid]
s.mu.RUnlock()
if ok {
c.renewNS.Store(now.UnixNano())
}
}
// leaseExpired reports whether the client's lease has lapsed under the
// given period. A period of zero or less disables lease enforcement, and
// a client the store does not know is not this store's business.
func (s *sessionStore) leaseExpired(clientid uint64, period time.Duration, now time.Time) bool {
if period <= 0 {
return false
}
s.mu.RLock()
c, ok := s.byID[clientid]
s.mu.RUnlock()
if !ok {
return false
}
return now.UnixNano()-c.renewNS.Load() > period.Nanoseconds()
}
// destroyClientID drops the client and its sessions and reports whether
// the client id was known.
func (s *sessionStore) destroyClientID(clientid uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.byID[clientid]
if !ok {
return false
}
for id := range c.sessions {
delete(s.sessions, id)
}
delete(s.byID, clientid)
for owner, e := range s.byOwner {
if e.client == c {
delete(s.byOwner, owner)
}
}
return true
}
// knownClient reports whether the client id is live.
func (s *sessionStore) knownClient(clientid uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.byID[clientid]
return ok
}
// attachCB binds a negotiated back channel to the session. The callback
// program the client named in CREATE_SESSION travels with the session,
// so a later BIND_CONN_TO_SESSION binds the connection under it.
func (s *sessionStore) attachCB(id nfs4.SessionID, cb *connCB) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[id]; ok {
sess.cb = cb
cb.setProgram(sess.cbProg)
}
}
// queueCB posts one fire and forget CB_COMPOUND behind a CB_SEQUENCE
// on the session's back channel. The sequence number is drawn and the
// work enqueued under one lock, so the order the connection's worker
// sends in is the order the numbers were drawn in. The done action runs
// on the worker when delivery ends.
func (s *sessionStore) queueCB(id nfs4.SessionID, tag string, ops [][]byte, done func(cbResult)) error {
sess, ok := s.lookupSessionPtr(id)
if !ok {
return errNoBackChannel("no such session")
}
sess.cbMu.Lock()
defer sess.cbMu.Unlock()
if sess.cb == nil {
return errNoBackChannel("session has no back channel")
}
sess.cbSeq++
seqArgs := nfs4.AppendCBSequenceArgs(nil, id, sess.cbSeq, 0, 0, true)
all := append([][]byte{seqArgs}, ops...)
return sess.cb.post(tag, id.ClientIDOf(), all, done)
}
// callCB posts one CB_COMPOUND the same way and waits for its delivery.
func (s *sessionStore) callCB(id nfs4.SessionID, tag string, ops [][]byte) (nfs4.CompoundRes, [][]byte, error) {
sess, ok := s.lookupSessionPtr(id)
if !ok {
return nfs4.CompoundRes{}, nil, errNoBackChannel("no such session")
}
sess.cbMu.Lock()
if sess.cb == nil {
sess.cbMu.Unlock()
return nfs4.CompoundRes{}, nil, errNoBackChannel("session has no back channel")
}
sess.cbSeq++
seqArgs := nfs4.AppendCBSequenceArgs(nil, id, sess.cbSeq, 0, 0, true)
w := cbWork{tag: tag, clientid: id.ClientIDOf(),
ops: append([][]byte{seqArgs}, ops...),
result: make(chan cbResult, 1)}
err := sess.cb.tryQueue(w)
sess.cbMu.Unlock()
if err != nil {
return nfs4.CompoundRes{}, nil, err
}
r := <-w.result
return r.res, r.bodies, r.err
}
// errNoBackChannel marks a callback that has no channel to travel on.
func errNoBackChannel(why string) error {
return fmt.Errorf("nfs4server: %s", why)
}
// lookupSessionPtr resolves a session id to the session pointer.
func (s *sessionStore) lookupSessionPtr(id nfs4.SessionID) (*session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
return sess, ok
}
// lookupSession resolves a session id to the session.
func (s *sessionStore) lookupSession(id nfs4.SessionID) (*session, uint32) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return nil, nfs4.ErrBadSession
}
return sess, nfs4.ErrOK
}
// sessionOfClient resolves the session of the client its callbacks
// travel on, preferring one with a live back channel, so a recall never
// fails while another session of the client could carry it.
func (s *sessionStore) sessionOfClient(clientid uint64) (nfs4.SessionID, bool) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.byID[clientid]
if !ok {
return nfs4.SessionID{}, false
}
fallback := nfs4.SessionID{}
have := false
for id, sess := range c.sessions {
if sess.cb != nil {
return id, true
}
fallback, have = id, true
}
return fallback, have
}
+643
View File
@@ -0,0 +1,643 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
crand "crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
// randCounter draws a random starting value for a state counter, so the
// other field of a stateid this server mints is difficult to guess,
// RFC 8881 section 8.2.2. A counter that started at one would let a
// client walk another client's stateids by enumeration.
func randCounter() uint64 {
var b [8]byte
if _, err := crand.Read(b[:]); err != nil {
panic("nfs4server: the random source failed: " + err.Error())
}
return binary.BigEndian.Uint64(b[:])
}
var dbgMu sync.Mutex
// stateidSeq answers the sequence field of a stateid as the 32 bit
// big-endian word the wire carries, RFC 7863.
func stateidSeq(st nfs4.Stateid) uint32 {
return binary.BigEndian.Uint32(st[0:4])
}
// setStateidSeq writes the sequence field of a stateid as a big-endian
// word.
func setStateidSeq(st *nfs4.Stateid, seq uint32) {
binary.BigEndian.PutUint32(st[0:4], seq)
}
// shareConflict reports whether a new open with the given access and deny
// bits collides with an existing one on the same file. A requested access
// fights an existing deny of the same kind, and a requested deny fights an
// existing access.
func shareConflicts(access, deny uint32, existing *openEntry) bool {
if access&nfs4.ShareAccessRead != 0 && existing.deny&nfs4.ShareDenyRead != 0 {
return true
}
if access&nfs4.ShareAccessWrite != 0 && existing.deny&nfs4.ShareDenyWrite != 0 {
return true
}
if deny&nfs4.ShareDenyRead != 0 && existing.access&nfs4.ShareAccessRead != 0 {
return true
}
if deny&nfs4.ShareDenyWrite != 0 && existing.access&nfs4.ShareAccessWrite != 0 {
return true
}
return false
}
// An openEntry is one live OPEN: the stateid the client holds, the share
// reservation it made, and the file it points at. A recovered open is
// one the store loaded back after a server restart: it still carries
// the client id of its past life, which a CLAIM_PREVIOUS in the grace
// window rebinds to the client that comes back for it.
type openEntry struct {
stateid nfs4.Stateid
clientID uint64
owner []byte
fh nfsfs.Handle
fileKey string
access uint32
deny uint32
delegSt *nfs4.Stateid
recovered bool
}
// stateStore keeps the OPEN state of the server: every live open, the
// share reservations grouped per file, and the tombstones of closed
// stateids, which turn a reuse of an old stateid into NFS4ERR_OLD_STATEID
// instead of the vaguer NFS4ERR_BAD_STATEID.
type stateStore struct {
mu sync.Mutex
next uint64
dir string // when set, live opens persist here across restarts
opens map[string]*openEntry
byFile map[string][]*openEntry
closed map[string]uint32
}
func newStateStore(dir string) *stateStore {
s := &stateStore{
next: randCounter(),
dir: dir,
opens: make(map[string]*openEntry),
byFile: make(map[string][]*openEntry),
closed: make(map[string]uint32),
}
if dir != "" {
s.load(dir)
}
return s
}
// persist writes the live opens into dir, atomically.
func (s *stateStore) persist(dir string) {
if dir == "" {
return
}
s.mu.Lock()
list := make([]persistedOpen, 0, len(s.opens))
for other, e := range s.opens {
list = append(list, persistedOpen{
Other: hex.EncodeToString([]byte(other)),
Seqid: stateidSeq(e.stateid),
ClientID: e.clientID,
FileKey: hex.EncodeToString([]byte(e.fileKey)),
Access: e.access,
Deny: e.deny,
})
}
s.mu.Unlock()
image, err := json.Marshal(map[string][]persistedOpen{"opens": list})
if err != nil {
return
}
tmp := filepath.Join(dir, "opens.json.tmp")
if err := os.WriteFile(tmp, image, 0o600); err != nil {
return
}
_ = os.Rename(tmp, filepath.Join(dir, "opens.json"))
}
// loadDir reads the persisted opens from dir into the store.
func (s *stateStore) load(dir string) {
data, err := os.ReadFile(filepath.Join(dir, "opens.json"))
if err != nil {
return
}
var image struct {
Opens []persistedOpen `json:"opens"`
}
if json.Unmarshal(data, &image) != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range image.Opens {
other, derr := hex.DecodeString(p.Other)
if derr != nil || len(other) != 12 {
continue
}
fileKeyBytes, derr := hex.DecodeString(p.FileKey)
if derr != nil {
continue
}
var st nfs4.Stateid
setStateidSeq(&st, p.Seqid)
copy(st[4:], other)
e := &openEntry{
stateid: st,
clientID: p.ClientID,
fileKey: string(fileKeyBytes),
access: p.Access,
deny: p.Deny,
recovered: true,
}
s.opens[string(other)] = e
s.byFile[e.fileKey] = append(s.byFile[e.fileKey], e)
}
}
// fileKey names one file across all its handles: the backend handle bytes
// already encode the stable identity of the file.
func fileKey(fh nfsfs.Handle) string {
return string(fh)
}
// open registers a new OPEN of a file, enforcing the share reservations of
// the opens already live on it. It answers the stateid of the new open.
func (s *stateStore) open(fh nfsfs.Handle, clientid uint64, owner []byte, access, deny uint32) (nfs4.Stateid, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.byFile[key] {
if shareConflicts(access, deny, e) {
return nfs4.Stateid{}, nfs4.ErrShareDenied
}
}
s.next++
var other [12]byte
copy(other[:4], []byte("OPEN"))
otherUint := s.next
other[4] = byte(otherUint >> 56)
other[5] = byte(otherUint >> 48)
other[6] = byte(otherUint >> 40)
other[7] = byte(otherUint >> 32)
other[8] = byte(otherUint >> 24)
other[9] = byte(otherUint >> 16)
other[10] = byte(otherUint >> 8)
other[11] = byte(otherUint)
st := nfs4.Stateid{}
setStateidSeq(&st, 1) // the stateid of the first state change
copy(st[4:], other[:])
e := &openEntry{
stateid: st,
clientID: clientid,
owner: owner,
fh: fh,
fileKey: key,
access: access,
deny: deny,
}
s.opens[string(other[:])] = e
s.byFile[key] = append(s.byFile[key], e)
return st, nfs4.ErrOK
}
// close releases an OPEN by its stateid. Only the client the open
// belongs to may close it. A stateid older than the live one answers
// NFS4ERR_OLD_STATEID, an unknown one NFS4ERR_BAD_STATEID.
func (s *stateStore) close(st nfs4.Stateid, clientid uint64) (nfs4.Stateid, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if e.clientID != clientid {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
switch {
case stateidSeq(st) == 0: // a cleared sequence names the current version
case stateidSeq(st) < stateidSeq(e.stateid):
return nfs4.Stateid{}, nfs4.ErrOldStateid
case stateidSeq(st) > stateidSeq(e.stateid):
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
closed := e.stateid
setStateidSeq(&closed, stateidSeq(e.stateid)+1) // CLOSE answers a dead stateid
delete(s.opens, string(other))
s.closed[string(other)] = stateidSeq(closed)
list := s.byFile[e.fileKey]
for i, cand := range list {
if cand == e {
s.byFile[e.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
return closed, nfs4.ErrOK
}
// currentStateid is the special CURRENT_STATEID of RFC 8881 section
// 8.2.3: sequence one and an empty other field name the most recent
// stateid the caller holds on the file.
func currentStateid() nfs4.Stateid {
var st nfs4.Stateid
setStateidSeq(&st, 1)
return st
}
// checkStateid resolves a stateid handed to READ, WRITE or SETATTR. The
// anonymous forms pass through with no entry; CURRENT_STATEID resolves
// to the caller's open of the file; anything else must name a live open
// of the same file that belongs to the asking client.
func (s *stateStore) checkStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) (uint32, uint32) {
allOnes := nfs4.Stateid{}
for i := range allOnes {
allOnes[i] = 0xff
}
if st == allOnes || st == (nfs4.Stateid{}) {
return 0, nfs4.ErrOK
}
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
var e *openEntry
if st == currentStateid() {
// The current stateid names the caller's own open of this file.
for _, cand := range s.byFile[fileKey(fh)] {
if cand.clientID == clientid {
e = cand
break
}
}
if e == nil {
return 0, nfs4.ErrBadStateid
}
} else {
var ok bool
e, ok = s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return 0, nfs4.ErrOldStateid
}
return 0, nfs4.ErrBadStateid
}
}
if e.clientID != clientid {
return 0, nfs4.ErrBadStateid
}
// A zero sequence names whatever version is current, RFC 8881
// section 8.2.2: conformant clients present stateids with the
// sequence field cleared, and the server honours them as the live
// version.
if st != currentStateid() && stateidSeq(st) != 0 {
if stateidSeq(st) < stateidSeq(e.stateid) {
return 0, nfs4.ErrOldStateid
}
if stateidSeq(st) > stateidSeq(e.stateid) {
return 0, nfs4.ErrBadStateid
}
}
if e.fileKey != fileKey(fh) {
return 0, nfs4.ErrBadStateid
}
return e.access, nfs4.ErrOK
}
// lookupOpen resolves a stateid to its live open entry, checking that
// the stateid names the file the caller says it does and belongs to the
// asking client. The anonymous forms never resolve here.
func (s *stateStore) lookupOpen(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) (*openEntry, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
return nil, nfs4.ErrBadStateid
}
if e.fileKey != fileKey(fh) || e.clientID != clientid {
return nil, nfs4.ErrBadStateid
}
return e, nfs4.ErrOK
}
// downgrade reduces the share access and deny bits of a live open. The
// stateid sequence moves one up; an older stateid is OLD_STATEID, an
// unknown one BAD_STATEID. Only the client the open belongs to may
// narrow it.
func (s *stateStore) downgrade(st nfs4.Stateid, clientid uint64, access, deny uint32) (nfs4.Stateid, uint32) {
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok {
if last, was := s.closed[string(other)]; was && stateidSeq(st) <= last {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if e.clientID != clientid {
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
if stateidSeq(st) != stateidSeq(e.stateid) && stateidSeq(st) != 0 {
// A cleared sequence names the current version, RFC 8881
// section 8.2.2.
if stateidSeq(st) < stateidSeq(e.stateid) {
return nfs4.Stateid{}, nfs4.ErrOldStateid
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
// The narrowed bits must not collide with the other opens of the file.
for _, cand := range s.byFile[e.fileKey] {
if cand == e {
continue
}
if shareConflicts(access, deny, cand) {
return nfs4.Stateid{}, nfs4.ErrShareDenied
}
}
e.access = access
e.deny = deny
setStateidSeq(&e.stateid, stateidSeq(e.stateid)+1)
return e.stateid, nfs4.ErrOK
}
// dropClient releases every OPEN of the client, which is what
// DESTROY_CLIENTID and lease expiry require.
func (s *stateStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for other, e := range s.opens {
if e.clientID == clientid {
delete(s.opens, other)
list := s.byFile[e.fileKey]
for i, cand := range list {
if cand == e {
s.byFile[e.fileKey] = append(list[:i], list[i+1:]...)
break
}
}
}
}
}
// A delegation is one granted OPEN delegation: the stateid the client
// holds, the session its recalls travel over, and the file it names.
type delegation struct {
stateid nfs4.Stateid
sessID nfs4.SessionID
fileKey string
clientID uint64
kind uint32 // nfs4.OpenDelegRead or nfs4.OpenDelegWrite
}
// delegStore tracks the live delegations. A file carries at most one; the
// store only answers whether a grant is possible and who holds what;
// the recall travels over the holder's back channel.
type delegStore struct {
mu sync.Mutex
next uint64
byKey map[string]*delegation // one per file key
}
func newDelegStore() *delegStore {
return &delegStore{next: randCounter(), byKey: make(map[string]*delegation)}
}
// grant registers a delegation of the file for the client and answers the
// delegation stateid. A file already delegated to somebody else is
// refused, which keeps the grants exclusive.
func (s *delegStore) grant(sessID nfs4.SessionID, clientid uint64, key string, kind uint32) (nfs4.Stateid, uint32) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byKey[key]; ok {
return nfs4.Stateid{}, nfs4.ErrDenied
}
s.next++
var other [12]byte
copy(other[:4], []byte("DELE"))
be := uint64(s.next)
for i := range 8 {
other[11-i] = byte(be >> (8 * i))
}
var st nfs4.Stateid
setStateidSeq(&st, 1)
copy(st[4:], other[:])
d := &delegation{stateid: st, sessID: sessID, fileKey: key, clientID: clientid, kind: kind}
s.byKey[key] = d
return st, nfs4.ErrOK
}
// holder returns the live delegation of a file, if any.
func (s *delegStore) holder(key string) (*delegation, bool) {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.byKey[key]
return d, ok
}
// revoke drops the delegation of a file.
func (s *delegStore) revoke(key string) {
s.mu.Lock()
delete(s.byKey, key)
s.mu.Unlock()
}
// dropClient releases every delegation the client holds.
func (s *delegStore) dropClient(clientid uint64) {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byKey {
if d.clientID == clientid {
delete(s.byKey, key)
}
}
}
// countOpens reports how many live opens the file carries.
func (s *stateStore) countOpens(fh nfsfs.Handle) int {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
return len(s.byFile[key])
}
// bindDelegation records the delegation stateid on the open, so a CLOSE
// of the open revokes the delegation with it.
func (s *stateStore) bindDelegation(open nfs4.Stateid, deleg nfs4.Stateid) {
other := open[4:]
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.opens[string(other)]; ok {
e.delegSt = &deleg
}
}
// delegOf reports the delegation bound to an open, if any.
func (s *stateStore) delegOf(open nfs4.Stateid) (nfs4.Stateid, bool) {
other := open[4:]
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.opens[string(other)]
if !ok || e.delegSt == nil {
return nfs4.Stateid{}, false
}
return *e.delegSt, true
}
// A persistedOpen is the on disk image of one live OPEN: enough to
// re-register the state after a server restart, so the handles clients
// hold keep their state across the restart.
type persistedOpen struct {
Other string `json:"other"` // hex of the 12 byte other field
Seqid uint32 `json:"seqid"`
ClientID uint64 `json:"clientid"`
FileKey string `json:"file_key"` // hex of the backend file key
Access uint32 `json:"access"`
Deny uint32 `json:"deny"`
}
// reclaimOpen resolves a CLAIM_PREVIOUS open: the pre restart open of
// the file whose handle the client presented. The client's own open
// answers first; otherwise a recovered open of the file, one the store
// loaded back after the restart, is rebound to the claiming client,
// because client ids do not survive a restart and the grace window is
// the only gate. It answers the open's stateid by value, so the caller
// holds no pointer into the store. When nothing answers it returns
// BAD_STATEID.
func (s *stateStore) reclaimOpen(fh nfsfs.Handle, clientid uint64) (nfs4.Stateid, uint32) {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.byFile[key] {
if e.clientID == clientid {
e.recovered = false
return e.stateid, nfs4.ErrOK
}
}
for _, e := range s.byFile[key] {
if e.recovered {
e.clientID = clientid
e.recovered = false
return e.stateid, nfs4.ErrOK
}
}
return nfs4.Stateid{}, nfs4.ErrBadStateid
}
// dropStateid removes the delegation the stateid names, when it belongs
// to the asking client, and reports whether there was one, which
// DELEGRETURN requires.
func (s *delegStore) dropStateid(st nfs4.Stateid, clientid uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, d := range s.byKey {
if d.stateid == st {
if d.clientID != clientid {
return false
}
delete(s.byKey, key)
return true
}
}
return false
}
// revokeIf drops the delegation of the file when it is still the one the
// recall named, so a recall that completes after a fresh grant never
// kills the new holder.
func (s *delegStore) revokeIf(key string, st nfs4.Stateid) {
s.mu.Lock()
defer s.mu.Unlock()
if d, ok := s.byKey[key]; ok && d.stateid == st {
delete(s.byKey, key)
}
}
// hasStateid reports whether the delegation stateid is live.
func (s *delegStore) hasStateid(st nfs4.Stateid) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.byKey {
if d.stateid == st {
return true
}
}
return false
}
// checkDataStateid validates a stateid handed to a data operation
// against the delegation store: RFC 8881 sections 8.2.3 and 10.3 let a
// client present the delegation stateid of the file to READ, WRITE and
// their kin. The stateid must be live, name the asking client and name
// this very file.
func (s *delegStore) checkDataStateid(st nfs4.Stateid, fh nfsfs.Handle, clientid uint64) uint32 {
key := fileKey(fh)
s.mu.Lock()
defer s.mu.Unlock()
for _, d := range s.byKey {
// The presented sequence is irrelevant: a conformant client
// may present the stateid with the sequence field cleared,
// RFC 8881 section 8.2.2.
if string(d.stateid[4:]) == string(st[4:]) {
if d.clientID != clientid || d.fileKey != key {
return nfs4.ErrBadStateid
}
return nfs4.ErrOK
}
}
return nfs4.ErrBadStateid
}
// testStateid reports the status of one stateid against the open store:
// the answer TEST_STATEID hands back without touching any state.
func (s *stateStore) testStateid(st nfs4.Stateid) uint32 {
allOnes := nfs4.Stateid{}
for i := range allOnes {
allOnes[i] = 0xff
}
if st == allOnes || st == (nfs4.Stateid{}) {
return nfs4.ErrOK
}
other := st[4:]
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.opens[string(other)]; ok {
if stateidSeq(st) < stateidSeq(e.stateid) {
return nfs4.ErrOldStateid
}
if stateidSeq(st) > stateidSeq(e.stateid) {
return nfs4.ErrBadStateid
}
return nfs4.ErrOK
}
if last, was := s.closed[string(other)]; was {
if stateidSeq(st) <= last {
return nfs4.ErrOldStateid
}
}
return nfs4.ErrBadStateid
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
)
func TestStateStoreDelegOfAndGrace(t *testing.T) {
s := newStateStore("")
fh := nfsfs.Handle([]byte("delegated-file"))
st, status := s.open(fh, 7, []byte("owner"), nfs4.ShareAccessRead, 0)
if status != nfs4.ErrOK {
t.Fatalf("open: %d", status)
}
if _, ok := s.delegOf(st); ok {
t.Fatal("no delegation bound yet")
}
deleg := nfs4.Stateid{1, 'D', 'E', 'L'}
s.bindDelegation(st, deleg)
got, ok := s.delegOf(st)
if !ok || got != deleg {
t.Fatalf("delegOf %v %v", got, ok)
}
// Only the owning client may close or downgrade the open; a foreign
// stateid use is BAD_STATEID even when the stateid itself is live.
if _, status := s.close(st, 8); status != nfs4.ErrBadStateid {
t.Fatalf("a foreign close: %d", status)
}
if _, status := s.downgrade(st, 8, nfs4.ShareAccessRead, 0); status != nfs4.ErrBadStateid {
t.Fatalf("a foreign downgrade: %d", status)
}
if _, status := s.checkStateid(st, fh, 8); status != nfs4.ErrBadStateid {
t.Fatalf("a foreign stateid check: %d", status)
}
if _, status := s.close(st, 7); status != nfs4.ErrOK {
t.Fatalf("the owner's close: %d", status)
}
// The grace window: a reclaim inside the window passes once, the
// second announcement and every reclaim after it fail with the
// error that names the reason.
g := newGrace(time.Minute)
if !g.active(time.Now()) {
t.Fatal("grace inactive at birth")
}
if ok, status := g.reclaimOKFor(9, time.Now()); !ok || status != 0 {
t.Fatalf("reclaim refused inside the window: %d", status)
}
if !g.complete(9, time.Now()) {
t.Fatal("first complete refused")
}
if g.complete(9, time.Now()) {
t.Fatal("second complete accepted")
}
if ok, status := g.reclaimOKFor(9, time.Now()); ok || status != nfs4.ErrCompleteAlready {
t.Fatalf("done client still reclaimable: %d", status)
}
if ok, status := g.reclaimOKFor(9, time.Now().Add(time.Hour)); ok || status != nfs4.ErrNoGrace {
t.Fatalf("reclaim accepted after the window: %d", status)
}
// FREE_STATEID removes the owner state the byOwn index points at,
// and only for the owner.
locks := newLockStore()
lst, status := locks.lock(fh, 5, []byte("lock-owner"), true, 0, 10)
if status != nfs4.ErrOK {
t.Fatalf("lock: %d", status)
}
if status := locks.freeStateid(lst, 6); status != nfs4.ErrBadStateid {
t.Fatalf("a foreign free: %d", status)
}
if status := locks.freeStateid(lst, 5); status != nfs4.ErrLocksHeld {
t.Fatalf("free with ranges: %d", status)
}
if _, status := locks.unlock(lst, 6, 0, 0); status != nfs4.ErrBadStateid {
t.Fatalf("a foreign unlock: %d", status)
}
if _, status := locks.unlock(lst, 5, 0, 0); status != nfs4.ErrOK {
t.Fatalf("unlock: %d", status)
}
if status := locks.freeStateid(lst, 5); status != nfs4.ErrOK {
t.Fatalf("free: %d", status)
}
locks.releaseOwner(5, []byte("lock-owner"))
}
// Re-locking a range the owner already holds merges into one range
// instead of piling entries up, RFC 8881 section 18.10.
func TestLockRangesMerge(t *testing.T) {
locks := newLockStore()
fh := nfsfs.Handle([]byte("merge-file"))
if _, status := locks.lock(fh, 1, []byte("o"), true, 0, 10); status != nfs4.ErrOK {
t.Fatalf("first lock: %d", status)
}
if _, status := locks.lock(fh, 1, []byte("o"), true, 10, 10); status != nfs4.ErrOK {
t.Fatalf("second lock: %d", status)
}
if _, status := locks.lock(fh, 1, []byte("o"), true, 20, 10); status != nfs4.ErrOK {
t.Fatalf("third lock: %d", status)
}
// The merged span answers conflicts as one range, 0 to 30.
denied, dstatus := locks.test(fh, 2, []byte("other"), true, 5, 1)
if dstatus != nfs4.ErrDenied || denied.Offset != 0 || denied.Length != 30 {
t.Fatalf("the merged range answers conflicts as one: %+v %d", denied, dstatus)
}
// A read lock inside the write span carves its bytes out of the write
// form: a read probe over the carved bytes passes, one over the
// neighbouring write bytes still conflicts.
if _, status := locks.lock(fh, 1, []byte("o"), false, 5, 1); status != nfs4.ErrOK {
t.Fatalf("read inside write: %d", status)
}
if _, dstatus := locks.test(fh, 2, []byte("other"), false, 5, 1); dstatus != nfs4.ErrOK {
t.Fatalf("a read probe over the carved read range conflicted: %d", dstatus)
}
if _, dstatus := locks.test(fh, 2, []byte("other"), false, 6, 1); dstatus != nfs4.ErrDenied {
t.Fatalf("a read probe over the neighbouring write range passed: %d", dstatus)
}
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/server"
)
// testCertificate builds a self signed certificate for the TLS tests.
func testCertificate(t *testing.T) *tls.Certificate {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "nfs.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatal(err)
}
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
}
// testRootPool trusts the self signed certificate.
func testRootPool(t *testing.T, cert *tls.Certificate) *x509.CertPool {
t.Helper()
pool := x509.NewCertPool()
pool.AddCert(leafOf(t, cert))
return pool
}
func leafOf(t *testing.T, cert *tls.Certificate) *x509.Certificate {
t.Helper()
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
t.Fatal(err)
}
return leaf
}
// The AUTH_TLS probe upgrades the connection and every further call
// rides the encrypted channel; a probe inside an upgraded session and a
// probe on a non NULL procedure are refused.
func TestRPCWithTLS(t *testing.T) {
h := testTree(t)
cert := testCertificate(t)
h.TLSConfig = &tls.Config{Certificates: []tls.Certificate{*cert}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
defer ln.Close()
cfg := &tls.Config{ServerName: "localhost", RootCAs: testRootPool(t, cert)}
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.EnableTLS(cfg); err != nil {
t.Fatalf("enable tls: %v", err)
}
// The session and the compounds ride the encrypted channel.
if err := cl.Establish("tls"); err != nil {
t.Fatalf("establish: %v", err)
}
res, _, err := cl.Compound("tls", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrSize)),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("compound: status %d %v", res.Status, err)
}
// A second probe inside the upgraded session is refused: the client
// reports the rejection instead of succeeding silently.
if err := cl.EnableTLS(cfg); err == nil {
t.Fatal("second probe inside a tls session succeeded")
}
}
// A handler without a TLSConfig refuses the probe.
func TestTLSRefusedWithoutConfig(t *testing.T) {
h := testTree(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
defer ln.Close()
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
if err := cl.EnableTLS(&tls.Config{InsecureSkipVerify: true}); err == nil {
t.Fatal("the upgrade succeeded without a server side config")
}
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/server"
)
// benchCert mints one self signed certificate for the TLS tests.
func benchCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("key: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "nfs-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// serveTLS starts the handler behind a loopback listener with the given
// certificate and returns the address.
func serveTLS(t *testing.T, cert tls.Certificate) string {
t.Helper()
root := t.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("NewLocal: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
handler := &nfs4server.Handler{FS: backend,
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}}}
go func() { _ = (&server.Server{Handle: handler.HandleConn}).Serve(t.Context(), ln) }()
return ln.Addr().String()
}
// TestWireTLSSession drives a full session over an in place TLS upgrade:
// the probe answers the STARTTLS token, the handshake runs, and a
// compound rides the encrypted connection.
func TestWireTLSSession(t *testing.T) {
addr := serveTLS(t, benchCert(t))
cl, err := nfsclient.Dial(addr)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer cl.Close()
if err := cl.EnableTLS(&tls.Config{InsecureSkipVerify: true, ServerName: "localhost"}); err != nil {
t.Fatalf("EnableTLS: %v", err)
}
if err := cl.Establish("tls-test"); err != nil {
t.Fatalf("Establish: %v", err)
}
res, _, err := cl.Compound("tls-getattr", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType)),
})
if err != nil {
t.Fatalf("Compound: %v", err)
}
if res.Status != nfs4.ErrOK {
t.Fatalf("getattr status %d over TLS", res.Status)
}
}
// TestWirePlaintextRefusedUnderTLS covers the RFC 9289 policy: a client
// that skips STARTTLS and runs procedures in plaintext is refused with
// auth too weak, while the NULL procedure still answers.
func TestWirePlaintextRefusedUnderTLS(t *testing.T) {
addr := serveTLS(t, benchCert(t))
cl, err := nfsclient.Dial(addr)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer cl.Close()
if err := cl.Null(); err != nil {
t.Fatalf("the plaintext NULL no longer answers: %v", err)
}
if err := cl.Establish("plaintext-test"); err == nil {
t.Fatal("a plaintext session was accepted under a TLS certificate")
}
}
+202
View File
@@ -0,0 +1,202 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The wire level benchmarks: the server handler and the client library
// over a loopback connection, so one operation carries the whole stack,
// RPC and XDR and sessions included, on top of the backend.
package nfs4server_test
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
const wireChunk = 64 << 10
// benchWire serves one export over loopback, dials the client library at
// it and returns the client and the handle of one regular file of the
// given size. Setup runs once, outside the measured region.
func benchWire(b *testing.B, fileSize int) (*nfsclient.Client, []byte) {
b.Helper()
root := b.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
b.Fatalf("NewLocal: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
b.Fatalf("Listen: %v", err)
}
ctx, stop := context.WithCancel(context.Background())
srv := &server.Server{Handle: (&nfs4server.Handler{FS: backend}).HandleConn}
go func() { _ = srv.Serve(ctx, ln) }()
b.Cleanup(stop)
name := "benchfile"
if fileSize > 0 {
p := filepath.Join(root, name)
buf := make([]byte, 1<<20)
for i := range buf {
buf[i] = byte(i)
}
f, err := os.Create(p)
if err != nil {
b.Fatalf("Create: %v", err)
}
for written := 0; written < fileSize; written += len(buf) {
if _, err := f.Write(buf); err != nil {
b.Fatalf("Write: %v", err)
}
}
if err := f.Close(); err != nil {
b.Fatalf("Close: %v", err)
}
}
cl, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
b.Fatalf("Dial: %v", err)
}
b.Cleanup(func() { _ = cl.Close() })
if err := cl.Establish("bench"); err != nil {
b.Fatalf("Establish: %v", err)
}
res, bodies, err := cl.Compound("bench-lookup", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, name),
nfs4.AppendGetfh(nil),
})
if err != nil {
b.Fatalf("Compound: %v", err)
}
if res.Status != nfs4.ErrOK {
b.Fatalf("lookup status %d", res.Status)
}
fh, err := xdr.NewDecoder(bodies[len(bodies)-1]).VarOpaque()
if err != nil {
b.Fatalf("decode fh: %v", err)
}
return cl, fh
}
// compound runs one COMPOUND and requires NFS4ERR_SUCCESS of it.
func compound(b *testing.B, cl *nfsclient.Client, tag string, ops [][]byte) [][]byte {
b.Helper()
res, bodies, err := cl.Compound(tag, ops)
if err != nil {
b.Fatalf("%s: %v", tag, err)
}
if res.Status != nfs4.ErrOK {
b.Fatalf("%s: nfs status %d", tag, res.Status)
}
return bodies
}
// BenchmarkWireRead64K reads 64 KiB per COMPOUND from a 64 MiB file over
// the loopback connection, cycling through the offsets.
func BenchmarkWireRead64K(b *testing.B) {
cl, fh := benchWire(b, 64<<20)
off := uint64(0)
b.SetBytes(wireChunk)
b.ResetTimer()
for b.Loop() {
bodies := compound(b, cl, "bench-read", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendReadArgs(nil, nfs4.Stateid{}, off, wireChunk),
})
d := xdr.NewDecoder(bodies[len(bodies)-1])
if _, err := d.Bool(); err != nil {
b.Fatalf("decode eof: %v", err)
}
if _, err := d.VarOpaque(); err != nil {
b.Fatalf("decode data: %v", err)
}
off += wireChunk
if off > 64<<20-wireChunk {
off = 0
}
}
}
// BenchmarkWireWrite64K writes 64 KiB per COMPOUND to a preallocated
// 64 MiB file, cycling through the offsets, one open around the run.
func BenchmarkWireWrite64K(b *testing.B) {
cl, _ := benchWire(b, 64<<20)
bodies := compound(b, cl, "bench-open", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0, []byte("bench"), nfs4.ShareAccessBoth, 0,
true, 0o644, "benchfile"),
nfs4.AppendGetfh(nil),
})
fh, err := xdr.NewDecoder(bodies[len(bodies)-1]).VarOpaque()
if err != nil {
b.Fatalf("decode fh: %v", err)
}
var st nfs4.Stateid
stateBody := bodies[len(bodies)-2]
if len(stateBody) < len(st) {
b.Fatalf("open result carries %d bytes of stateid", len(stateBody))
}
copy(st[:], stateBody)
buf := make([]byte, wireChunk)
off := uint64(0)
b.SetBytes(wireChunk)
b.ResetTimer()
for b.Loop() {
compound(b, cl, "bench-write", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendWriteArgs(nil, st, off, nfs4.StableFileSync, buf),
})
off += wireChunk
if off > 64<<20-wireChunk {
off = 0
}
}
b.StopTimer()
compound(b, cl, "bench-close", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendCloseArgs(nil, st),
})
}
// BenchmarkWireGetattr asks the attributes of one file per COMPOUND.
func BenchmarkWireGetattr(b *testing.B) {
cl, fh := benchWire(b, 1<<20)
request := nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode)
b.ResetTimer()
for b.Loop() {
bodies := compound(b, cl, "bench-getattr", [][]byte{
nfs4.AppendPutfh(nil, fh),
nfs4.AppendGetattr(nil, request),
})
d := xdr.NewDecoder(bodies[len(bodies)-1])
if _, err := nfs4.ReadBitmap(d); err != nil {
b.Fatalf("decode bitmap: %v", err)
}
if _, err := d.VarOpaque(); err != nil {
b.Fatalf("decode attrs: %v", err)
}
}
}
// BenchmarkWireLookup resolves one name under the root per COMPOUND.
func BenchmarkWireLookup(b *testing.B) {
cl, _ := benchWire(b, 1<<20)
b.ResetTimer()
for b.Loop() {
compound(b, cl, "bench-lookup", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "benchfile"),
nfs4.AppendGetfh(nil),
})
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The handlers of the extended attribute family, RFC 8276. A backend
// without the XattrFS half answers NOT_SUPP to all of them.
package nfs4server
import (
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// xattrFS resolves the backend's xattr half, or nil when it has none.
func (h *Handler) xattrFS() nfsfs.XattrFS {
x, _ := h.FS.(nfsfs.XattrFS)
return x
}
// getXattrOp serves GETXATTR: one named value of the current object.
// The argument is the name alone, RFC 8276.
func (h *Handler) getXattrOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
name, err := d.String()
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
x := h.xattrFS()
if x == nil {
return nil, nfs4.ErrNotSupp, nil
}
value, xerr := x.GetXattr(reg.cur, name, 0)
if xerr == nfsfs.ErrNoXattr {
return nil, nfs4.ErrNoXattr, nil
}
if xerr == nfsfs.ErrXattrNotSupp {
return nil, nfs4.ErrNotSupp, nil
}
if xerr != nil {
return nil, mapErr(xerr), nil
}
return nfs4.AppendGetXattrRes(nil, value), nfs4.ErrOK, nil
}
// setXattrOp serves SETXATTR: it writes or creates or replaces one named
// value of the current object. The argument order is the mode, the key
// and the value, RFC 8276.
func (h *Handler) setXattrOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
mode, err := d.Uint32()
if err != nil {
return nil, 0, err
}
name, err := d.String()
if err != nil {
return nil, 0, err
}
value, err := d.VarOpaque()
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
x := h.xattrFS()
if x == nil {
return nil, nfs4.ErrNotSupp, nil
}
if err := x.SetXattr(reg.cur, name, value, mode); err != nil {
if err == nfsfs.ErrXattrNotSupp {
return nil, nfs4.ErrNotSupp, nil
}
return nil, mapErr(err), nil
}
return nil, nfs4.ErrOK, nil
}
// listXattrOp serves LISTXATTR: the names of the current object's
// attributes, one page bounded by the budget. The whole list answers in
// one page with the end of list flag set, so a resume cookie never
// names a remaining entry: cookie zero answers everything and any other
// cookie answers an empty final page, RFC 8276 section 5.
func (h *Handler) listXattrOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
cookie, err := d.Uint64()
if err != nil {
return nil, 0, err
}
maxcount, err := d.Uint32()
if err != nil {
return nil, 0, err
}
if maxcount > 1<<20 {
maxcount = 1 << 20
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
x := h.xattrFS()
if x == nil {
return nil, nfs4.ErrNotSupp, nil
}
names := []string{}
if cookie == 0 {
names, err = x.ListXattr(reg.cur, int(maxcount))
if err == nfsfs.ErrXattrNotSupp {
return nil, nfs4.ErrNotSupp, nil
}
if err != nil {
return nil, mapErr(err), nil
}
}
return nfs4.AppendListXattrRes(nil, cookie, names, true), nfs4.ErrOK, nil
}
// removeXattrOp serves REMOVEXATTR: it deletes one named value of the
// current object.
func (h *Handler) removeXattrOp(d *xdr.Decoder, reg *fhreg) ([]byte, uint32, error) {
name, err := d.String()
if err != nil {
return nil, 0, err
}
if !reg.haveCur {
return nil, nfs4.ErrNoFileHandle, nil
}
x := h.xattrFS()
if x == nil {
return nil, nfs4.ErrNotSupp, nil
}
if err := x.RemoveXattr(reg.cur, name); err != nil {
if err == nfsfs.ErrNoXattr {
return nil, nfs4.ErrNoXattr, nil
}
if err == nfsfs.ErrXattrNotSupp {
return nil, nfs4.ErrNotSupp, nil
}
return nil, mapErr(err), nil
}
return nil, nfs4.ErrOK, nil
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfs4server
import (
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
func TestXattrOverCompound(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
run := func(s uint32, ops [][]byte) (nfs4.CompoundRes, [][]byte) {
t.Helper()
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, s, 0, defaultSlots-1, true)}, ops...)
payload := nfs4.AppendCompoundArgs(nil, "xattr", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
r, bodies, derr := nfs4.DecodeCompoundResBodies(body)
if derr != nil {
t.Fatalf("decode: %v", derr)
}
return r, bodies[1:]
}
seq++
res, _ := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetXattrArgs(nil, "user.comment"),
})
wantStatus(t, "get before set", res.Status, nfs4.ErrNoXattr)
// Set, read back, list, remove: the whole life of one attribute.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.comment", []byte("hello")),
})
wantStatus(t, "set", res.Status, nfs4.ErrOK)
seq++
res, bodies := run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetXattrArgs(nil, "user.comment"),
})
wantStatus(t, "get", res.Status, nfs4.ErrOK)
value, gerr := nfs4.DecodeGetXattrRes(bodies[2])
if gerr != nil || string(value) != "hello" {
t.Fatalf("value %q: %v", value, gerr)
}
// CREATE on a live attribute is refused; REPLACE on it succeeds.
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrCreate, "user.comment", []byte("again")),
})
if res.Status == nfs4.ErrOK {
t.Fatal("create over a live attribute succeeded")
}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrReplace, "user.comment", []byte("second")),
})
wantStatus(t, "replace", res.Status, nfs4.ErrOK)
seq++
res, bodies = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendListXattrArgs(nil, 0, 1<<16),
})
wantStatus(t, "list", res.Status, nfs4.ErrOK)
ld := xdr.NewDecoder(bodies[2])
if ck, lerr := ld.Uint64(); lerr != nil || ck != 0 {
t.Fatalf("cookie %d: %v", ck, lerr)
}
n, lerr := ld.Uint32()
if lerr != nil || n != 1 {
t.Fatalf("names %d: %v", n, lerr)
}
name, lerr := ld.String()
if lerr != nil || name != "user.comment" {
t.Fatalf("name %q: %v", name, lerr)
}
if eof, lerr := ld.Bool(); lerr != nil || !eof {
t.Fatalf("eof %v: %v", eof, lerr)
}
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendRemoveXattrArgs(nil, "user.comment"),
})
wantStatus(t, "remove", res.Status, nfs4.ErrOK)
seq++
res, _ = run(seq, [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetXattrArgs(nil, "user.comment"),
})
wantStatus(t, "get after remove", res.Status, nfs4.ErrNoXattr)
}
// A bare FS wrapper hides the backend's xattr half: the xattr family must
// answer NOT_SUPP against it.
type bareFS struct {
nfsfs.FS
}
func TestXattrWithoutBackend(t *testing.T) {
h := testTree(t)
h.FS = bareFS{h.FS}
sid, seq := newSession(t, h)
all := [][]byte{
nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "a.txt"),
nfs4.AppendGetXattrArgs(nil, "user.comment"),
nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.comment", []byte("v")),
nfs4.AppendListXattrArgs(nil, 0, 1<<16),
nfs4.AppendRemoveXattrArgs(nil, "user.comment"),
}
payload := nfs4.AppendCompoundArgs(nil, "xattr", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatal("garbage")
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatal(err)
}
// ops: sequence, putrootfh, lookup, then four xattr ops.
for i, op := range res.Ops[3:] {
if op.Status != nfs4.ErrNotSupp {
t.Fatalf("xattr op %d: status %d, want NOT_SUPP", i, op.Status)
}
}
}
func TestXattrWithoutFileHandle(t *testing.T) {
h := testTree(t)
sid, seq := newSession(t, h)
table := []struct {
name string
op []byte
}{
{"get", nfs4.AppendGetXattrArgs(nil, "user.a")},
{"set", nfs4.AppendSetXattrArgs(nil, nfs4.SetXattrUnchecked, "user.a", []byte("v"))},
{"list", nfs4.AppendListXattrArgs(nil, 0, 1<<16)},
{"remove", nfs4.AppendRemoveXattrArgs(nil, "user.a")},
}
for _, row := range table {
seq++
all := append([][]byte{nfs4.AppendSequenceArgs(nil, sid, seq, 0, defaultSlots-1, true)}, row.op)
payload := nfs4.AppendCompoundArgs(nil, "no-fh", nfs4.MinorVersion, all)
body, ok := h.compound(payload, cred{uid: 0, gid: 0})
if !ok {
t.Fatalf("%s: garbage", row.name)
}
res, _, err := nfs4.DecodeCompoundResBodies(body)
if err != nil {
t.Fatalf("%s: decode %v", row.name, err)
}
if res.Status != nfs4.ErrNoFileHandle {
t.Fatalf("%s: status %d, want NOFILE_HANDLE", row.name, res.Status)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsclient
import (
"net"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
)
// The accessors answer copies of what the callback demux recorded.
func TestNotifiedAccessors(t *testing.T) {
c := &Client{}
if got := c.Notified(); len(got) != 0 {
t.Fatal("notified before any delivery")
}
if got := c.NotifiedLocks(); len(got) != 0 {
t.Fatal("locks notified before any delivery")
}
c.recMu.Lock()
c.notified = append(c.notified, nfs4.CBNotify{FH: []byte("d")})
c.notifiedLocks = append(c.notifiedLocks, nfs4.CBNotifyLock{Clientid: 5})
c.recMu.Unlock()
if n := c.Notified(); len(n) != 1 || string(n[0].FH) != "d" {
t.Fatalf("notified %+v", n)
}
if n := c.NotifiedLocks(); len(n) != 1 || n[0].Clientid != 5 {
t.Fatalf("locks %+v", n)
}
}
// The callback demux answers CB_NOTIFY and CB_NOTIFY_LOCK and records
// them for the caller.
func TestCBCompoundNotifies(t *testing.T) {
// The demux answers every callback on the wire; a pipe with a
// draining side stands in for the connection.
a, b := net.Pipe()
defer a.Close()
defer b.Close()
go func() {
buf := make([]byte, 4096)
for {
if _, err := b.Read(buf); err != nil {
return
}
}
}()
c := &Client{conn: a, cbProgram: 0x40000000}
st := nfs4.Stateid{1, 'D', 'D'}
notify := nfs4.AppendCBNotifyArgs(nil, st, []byte("dir"),
[]nfs4.Notify4{{Mask: nfs4.OfBits(nfs4.NotifyAddEntry),
Vals: nfs4.AppendNotifyAdd(nil, "new", 0, true)}})
lock := nfs4.AppendCBNotifyLockArgs(nil, []byte("f"), 9, []byte("owner"))
c.handleCBCall(1, buildCBCall(t, [][]byte{notify, lock}))
notes := c.Notified()
if len(notes) != 1 || !notes[0].Changes[0].Mask.Has(nfs4.NotifyAddEntry) {
t.Fatalf("notified %+v", notes)
}
if n := c.NotifiedLocks(); len(n) != 1 || n[0].Clientid != 9 {
t.Fatalf("locks %+v", n)
}
}
// buildCBCall frames one CB_COMPOUND call record the way the server
// sends it.
func buildCBCall(t *testing.T, ops [][]byte) []byte {
t.Helper()
args := nfs4.AppendCBCompoundArgs(nil, "cb", nfs4.MinorVersion, 0, ops)
record, err := rpc.AppendCall(nil, rpc.Call{
XID: 1, Program: nfs4.CBDefaultProgram, Version: nfs4.Version,
Procedure: 1,
Cred: rpc.Auth{Flavor: rpc.FlavorNone},
Verifier: rpc.Auth{Flavor: rpc.FlavorNone},
})
if err != nil {
t.Fatal(err)
}
return append(record, args...)
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsclient
import (
"net"
"os"
"path/filepath"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
"sourcedock.dev/petrbalvin/nfs/internal/server"
)
// The RPCSEC_GSS integrity and privacy levels of this client round trip
// against the real server over TCP.
func TestGSSRoundTrip(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "k.txt"), []byte("kerberos"), 0o644); err != nil {
t.Fatal(err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 3)
}
h := &nfs4server.Handler{FS: backend, ServerKey: key, ServiceName: "nfs"}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: h.HandleConn}
go srv.Serve(t.Context(), ln)
defer ln.Close()
for _, svc := range []uint32{rpc.SvcIntegrity, rpc.SvcPrivacy} {
cl, err := Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
if err := cl.Establish("gss-client"); err != nil {
t.Fatalf("establish: %v", err)
}
if err := cl.EnableGSS(krb5.EtypeAES128, key, "EXAMPLE.ORG", "nfs",
"petr@EXAMPLE.ORG", svc); err != nil {
t.Fatalf("enable gss: %v", err)
}
res, _, err := cl.Compound("gss", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "k.txt"),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrSize)),
})
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrOK {
t.Fatalf("compound: status %d", res.Status)
}
if err := cl.DisableGSS(); err != nil {
t.Fatalf("disable: %v", err)
}
cl.Close()
}
}
+447
View File
@@ -0,0 +1,447 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsclient_test
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4server"
"sourcedock.dev/petrbalvin/nfs/internal/nfsclient"
"sourcedock.dev/petrbalvin/nfs/internal/nfsfs"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
"sourcedock.dev/petrbalvin/nfs/internal/server"
"sourcedock.dev/petrbalvin/nfs/internal/xdr"
)
// startServer runs the full stack, the TCP listener, the connection
// skeleton, the dispatcher and the local backend, on an ephemeral port.
func startServer(t *testing.T) *nfsclient.Client {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello over the wire"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatalf("backend: %v", err)
}
handler := &nfs4server.Handler{FS: backend}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &server.Server{Handle: handler.HandleConn}
go srv.Serve(t.Context(), listener)
c, err := nfsclient.Dial(listener.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
if err := c.Establish("probe-client"); err != nil {
t.Fatalf("establish: %v", err)
}
t.Cleanup(func() { c.Close(); listener.Close() })
return c
}
func TestNullPingOverTCP(t *testing.T) {
c := startServer(t)
if err := c.Null(); err != nil {
t.Fatalf("null: %v", err)
}
}
func TestCompoundRoundTripOverTCP(t *testing.T) {
c := startServer(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendGetfh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrSize, nfs4.AttrMode)),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 1024),
}
res, bodies, err := c.Compound("wire", ops)
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 5 {
t.Fatalf("status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(bodies[3])
if err != nil {
t.Fatalf("getattr: %v", err)
}
if attrs.Type != nfs4.NF4Reg || attrs.Size != 19 {
t.Fatalf("attrs: type %d size %d", attrs.Type, attrs.Size)
}
eof, data, err := nfs4.DecodeReadBody(bodies[4])
if err != nil || !eof || string(data) != "hello over the wire" {
t.Fatalf("read: %q eof %v, %v", data, eof, err)
}
}
func TestUnknownProgramAndProcedure(t *testing.T) {
c := startServer(t)
// An unknown procedure is answered at the RPC layer with PROC_UNAVAIL.
status, err := c.Procedure(999)
if err != nil {
t.Fatalf("procedure 999: %v", err)
}
if status != rpc.AcceptProcUnavail {
t.Fatalf("procedure 999 answered with status %d", status)
}
}
func TestRejectedOperationOverTCP(t *testing.T) {
c := startServer(t)
ops := [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "nope"),
}
res, _, err := c.Compound("miss", ops)
if err != nil {
t.Fatalf("compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt {
t.Fatalf("status %d, want NFS4ERR_NOENT", res.Status)
}
}
func TestCreateAndWriteOverTCP(t *testing.T) {
c := startServer(t)
// Make a directory over the wire, then a file inside it would come
// from OPEN, so the test writes into the file the fixture seeded by
// creating one directly in the tree the backend serves.
res, _, err := c.Compound("made-over-the-wire", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Dir, "made-over-the-wire", "", 0, 0,
0o755),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrType, nfs4.AttrMode)),
})
if err != nil {
t.Fatalf("create compound: %v", err)
}
if res.Status != nfs4.ErrOK {
t.Fatalf("create status %d", res.Status)
}
_, attrs, err := nfs4.DecodeGetattrBody(res.Ops[2].Body)
if err != nil || attrs.Type != nfs4.NF4Dir || attrs.Mode != 0o755 {
t.Fatalf("created dir: %+v, %v", attrs, err)
}
// Write into the seeded file and read back through a fresh session of
// COMPOUNDs.
res, _, err = c.Compound("write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendWriteArgs(nil, nfs4.AllZero, 0, nfs4.StableUnstable, []byte("WRITTEN OVER TCP")),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write compound: status %d, %v", res.Status, err)
}
count, committed, verf, err := nfs4.DecodeWriteRes(res.Ops[2].Body)
if err != nil || count != 16 || committed != nfs4.StableFileSync || verf == ([8]byte{}) {
t.Fatalf("write res: %d %d %x, %v", count, committed, verf, err)
}
res, _, err = c.Compound("read", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendRead(nil, nfs4.AllZero, 0, 1024),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("read compound: status %d, %v", res.Status, err)
}
eof, data, err := nfs4.DecodeReadBody(res.Ops[2].Body)
// The write replaced the head of the seeded file; its tail survives.
if err != nil || !eof || string(data) != "WRITTEN OVER TCPire" {
t.Fatalf("read after write: %q eof %v, %v", data, eof, err)
}
}
func TestRemoveAndRenameOverTCP(t *testing.T) {
c := startServer(t)
// Rename over the wire with the SAVEFH setup: the source directory in
// the saved handle, the target directory as the current one.
res, _, err := c.Compound("rename", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendSavefh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendRenameArgs(nil, "hello.txt", "moved.txt"),
})
if err != nil {
t.Fatalf("rename compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 4 {
t.Fatalf("rename status %d ops %d", res.Status, len(res.Ops))
}
// The old name is gone and the new one answers.
res, _, err = c.Compound("old-name", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
})
if err != nil {
t.Fatalf("old name compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt {
t.Fatalf("the old name survived: status %d", res.Status)
}
res, _, err = c.Compound("present", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendLookup(nil, "moved.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("the moved file is not under dir: status %d, %v", res.Status, err)
}
// REMOVE clears the entry from its directory.
res, _, err = c.Compound("remove", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendRemoveArgs(nil, "moved.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("remove: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("gone", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendLookup(nil, "moved.txt"),
})
if err != nil {
t.Fatalf("gone compound: %v", err)
}
if res.Status != nfs4.ErrNoEnt || len(res.Ops) != 3 {
t.Fatalf("gone: status %d ops %d", res.Status, len(res.Ops))
}
if res.Ops[2].Status != nfs4.ErrNoEnt {
t.Fatalf("lookup after remove status %d", res.Ops[2].Status)
}
}
func TestSetattrAndLinkOverTCP(t *testing.T) {
c := startServer(t)
// SETATTR mode and size over the wire, then read back through the
// same handle the server kept current.
res, _, err := c.Compound("setattr", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendGetfh(nil),
nfs4.AppendSetattrArgs(nil, nfs4.AllZero,
nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize),
nfs4.Attrs{Mode: 0o600, Size: 5}),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrMode, nfs4.AttrSize)),
})
if err != nil {
t.Fatalf("setattr compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 5 {
t.Fatalf("setattr: status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err := nfs4.DecodeGetattrBody(res.Ops[4].Body)
if err != nil || attrs.Mode != 0o600 || attrs.Size != 5 {
t.Fatalf("after setattr: %+v, %v", attrs, err)
}
// LINK with the saved handle pointing at the file: the link lands in
// the directory that is current.
res, _, err = c.Compound("link", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendSavefh(nil),
nfs4.AppendPutRootfh(nil),
nfs4.AppendLinkArgs(nil, "hard.txt"),
nfs4.AppendRestorefh(nil),
nfs4.AppendGetattr(nil, nfs4.OfBits(nfs4.AttrNumlinks)),
})
if err != nil {
t.Fatalf("link compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 7 {
t.Fatalf("link: status %d ops %d", res.Status, len(res.Ops))
}
_, attrs, err = nfs4.DecodeGetattrBody(res.Ops[6].Body)
if err != nil || attrs.Numlinks != 2 {
t.Fatalf("link count: %d, %v", attrs.Numlinks, err)
}
}
func TestReadlinkAndCommitOverTCP(t *testing.T) {
c := startServer(t)
// Create a symlink over the wire, then read it back.
res, _, err := c.Compound("mklink", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendCreateArgs(nil, nfs4.NF4Lnk, "wire-link", "hello.txt", 0, 0,
0o644),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("create symlink: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("readlink", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "wire-link"),
nfs4.AppendReadlinkArgs(nil),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("readlink: status %d, %v", res.Status, err)
}
target, err := xdr.NewDecoder(res.Ops[2].Body).String()
if err != nil || target != "hello.txt" {
t.Fatalf("readlink target: %q, %v", target, err)
}
// COMMIT of the written file answers with the server verifier.
res, _, err = c.Compound("commit", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "hello.txt"),
nfs4.AppendCommitArgs(nil, 0, 0, [8]byte{}),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("commit: status %d, %v", res.Status, err)
}
verf, err := xdr.NewDecoder(res.Ops[2].Body).Raw(8)
if err != nil {
t.Fatalf("commit body: %v", err)
}
if string(verf) == string(make([]byte, 8)) {
t.Fatal("the commit verifier is zero")
}
}
func TestSecinfoNoNameOverTCP(t *testing.T) {
c := startServer(t)
// The shape a Linux client uses: LOOKUP of the component it wants to
// mount, then SECINFO_NO_NAME for the current component.
res, _, err := c.Compound("secinfo", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "dir"),
nfs4.AppendSecinfoNoNameArgs(nil, nfs4.StyleCurrentFH),
})
if err != nil {
t.Fatalf("secinfo compound: %v", err)
}
if res.Status != nfs4.ErrOK || len(res.Ops) != 3 {
t.Fatalf("secinfo: status %d ops %d", res.Status, len(res.Ops))
}
entries, err := nfs4.DecodeSecinfoRes(res.Ops[2].Body)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(entries) != 1 || entries[0].Flavor != nfs4.SecFlavorSys {
t.Fatalf("secinfo entries: %+v", entries)
}
}
func TestOpenWriteCloseOverTCP(t *testing.T) {
c := startServer(t)
if err := c.Establish("integration-client"); err != nil {
t.Fatalf("establish: %v", err)
}
// OPEN with create, WRITE under the open stateid, CLOSE.
res, _, err := c.Compound("open", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendOpenArgs(nil, 0x7777, []byte("owner"), nfs4.ShareAccessBoth, 0,
true, 0o644, "opened-over-tcp.txt"),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("open: status %d, %v", res.Status, err)
}
var stateid nfs4.Stateid
copy(stateid[:], res.Ops[1].Body)
res, _, err = c.Compound("write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("stateful!")),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("write: status %d, %v", res.Status, err)
}
res, _, err = c.Compound("close", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendCloseArgs(nil, stateid),
})
if err != nil || res.Status != nfs4.ErrOK {
t.Fatalf("close: status %d, %v", res.Status, err)
}
// A write after the close is rejected as an old stateid.
res, _, err = c.Compound("late-write", [][]byte{
nfs4.AppendPutRootfh(nil),
nfs4.AppendLookup(nil, "opened-over-tcp.txt"),
nfs4.AppendWriteArgs(nil, stateid, 0, nfs4.StableFileSync, []byte("x")),
})
if err != nil || res.Status != nfs4.ErrOldStateid {
t.Fatalf("write after close: status %d, %v", res.Status, err)
}
}
// TestServesCallbackCalls drives a raw CB_COMPOUND from the server side
// of the connection to the client's callback dispatcher and checks the
// recall recording.
func TestServesCallbackCalls(t *testing.T) {
root := t.TempDir()
backend, err := nfsfs.NewLocal(root)
if err != nil {
t.Fatal(err)
}
handler := &nfs4server.Handler{FS: backend}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &server.Server{Handle: handler.HandleConn}
go srv.Serve(t.Context(), ln)
c, err := nfsclient.Dial(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.Establish("cb-client"); err != nil {
t.Fatalf("establish: %v", err)
}
sid, ok := c.SessionID()
if !ok {
t.Fatal("no session")
}
// The peer delivers a CB_COMPOUND: CB_SEQUENCE then CB_RECALL of a
// fake delegation stateid, through the raw send the back channel uses.
var fake nfs4.Stateid
fake[0] = 0xde
probe := [][]byte{
nfs4.AppendCBRecallArgs(nil, fake, false, []byte{1, 2, 3}),
}
if _, _, err := handler.SendCB(sid, "recall", probe); err != nil {
t.Fatalf("sendCB: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if rec := c.Recalled(); len(rec) == 1 && rec[0] == fake {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the recall never reached the client")
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package nfsclient
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/nfs/internal/nfs4"
)
// The NFS error carries its status into text a caller can log.
func TestNFSErrorText(t *testing.T) {
err := &NFSError{Status: nfs4.ErrNoEnt}
if !strings.Contains(err.Error(), "2") {
t.Fatalf("error text %q", err.Error())
}
if err.Error() == "" {
t.Fatal("empty error text")
}
}

Some files were not shown because too many files have changed in this diff Show More