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
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:
@@ -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/...
|
||||
@@ -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);
|
||||
'
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
module sourcedock.dev/petrbalvin/nfs
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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[:]...)
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user