Repository navigation
Map RENAME and READLINK failures to the NFS statuses that name them - #1
Conversation
Both handlers threw away the reason a filesystem gave them:
- onRename's ladder tested only os.IsNotExist and os.IsPermission, so
every other way a rename can fail - EEXIST and ENOTEMPTY for a
destination directory that already holds entries, EISDIR and ENOTDIR
for a type mismatch between the two names, EXDEV, EROFS, ENOSPC -
left as NFS3ERR_IO. RFC 1813 gives RENAME a status for each of them,
and a client that gets NFS3ERR_IO can only pass it on as EIO, which
says nothing an application can act on and reads like a failing disk.
- onReadLink decided its status from a Stat of the same name before
looking at the readlink error at all. readlink(2) already reports
EINVAL for a name that is not a symlink, so the errno was discarded
in favour of a guess - one that comes out wrong on any filesystem
whose Stat follows the link, where the mode is never ModeSymlink and
the handler fell through to NFS3ERR_ACCES. The Inval branch also
wrapped the (nil) Stat error rather than the readlink error, so the
cause never reached the server log either.
Adds statusFromError, which names an error by its errno and by the os
sentinels the filesystems that are not backed by the OS return instead,
and reports whether it found a status at all - the caller picks the
fallback, since what a client will accept depends on the operation.
Order matters in that ladder: syscall.Errno reports both EEXIST and
ENOTEMPTY as os.ErrExist and both EACCES and EPERM as os.ErrPermission,
so the narrower errno is tested first.
onReadLink now takes its status from the errno and keeps the Stat as a
fallback for filesystems that report no errno at all (memfs, for one,
reports "not a symlink" as an opaque error). An error neither can name
is NFS3ERR_IO rather than the NFS3ERR_ACCES it used to be; the wrapped
cause is kept on every path.
Note that os.Rename flattens every existing directory at the
destination into EEXIST, so a filesystem built on it cannot report
ENOTEMPTY or EISDIR; the distinction shows up on filesystems that call
renameat directly. Tests cover both, driving the real server and
reading the status word off the wire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeywBLV66AeSH8WuDfvv7Q
PR SummaryMedium Risk Overview The change adds shared errno-to-status mapping (with ordering so ENOTEMPTY beats os.ErrExist) and wires RENAME and parent Stat failures through it. READLINK prefers the readlink errno, uses Lstat only when the error cannot be named, and uses NFS3ERR_IO instead of ACCES for those unnamed failures. Unit and loopback integration tests cover the mapping ladder and wire statuses. Reviewed by Cursor Bugbot for commit 60396c6. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2834b15. Configure here.
Stat follows a symlink, so it reports the target's mode. The fallback read the symlink bit off it, which meant a readlink error the errno ladder could not name - EIO, or the opaque errors non-OS filesystems report - was answered as NFS3ERR_INVAL when the name was a symlink all along, exactly the stat-derived guess overriding the real error that this branch set out to remove. Lstat makes no attempt to follow the link, and is already what onLookup, onGetAttr and onSetAttr use to ask a node's own type; Stat here was the odd one out. The test for this covered it up: its filesystem wrapper stood Stat in for Lstat, so the handler never saw a link-following Stat. Dropping the override leaves memfs's own Stat in play, which follows a link just as the OS one does, and adds the error-without-an-errno case. Both fail against the previous commit for the right reason. Reported by Cursor Bugbot on the first commit of this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeywBLV66AeSH8WuDfvv7Q

Problem
Two handlers discard the reason the filesystem gave them, so conditions a client could act on arrive as
NFS3ERR_IO— which a client can only pass on asEIO, indistinguishable from a failing disk, and which callers further up have nothing better to do with than a 500.onRenametested onlyos.IsNotExistandos.IsPermission. Everything else fell through toNFS3ERR_IO, including every case RFC 1813 §3.3.14 gives RENAME a status for:ENOTEMPTY(destination directory holds entries)NFS3ERR_IONFS3ERR_NOTEMPTYEEXISTNFS3ERR_IONFS3ERR_EXISTEISDIR(file onto a directory)NFS3ERR_IONFS3ERR_ISDIRENOTDIR(directory onto a file)NFS3ERR_IONFS3ERR_NOTDIREXDEVNFS3ERR_IONFS3ERR_XDEVEROFS/ENOSPC/EDQUOT/EMLINK/ENAMETOOLONG/EINVALNFS3ERR_IOonReadLinkdecided its status from aStatof the same name before looking at the readlink error at all:readlink(2)already reportsEINVALfor a name that is not a symlink, so the errno was thrown away in favour of a guess — and the guess is wrong on any filesystem whoseStatfollows the link, where the mode is neverModeSymlinkand the handler fell through toNFS3ERR_ACCES.EINVAL,ENOTDIR,EACCESandEIOall reached the client as the wrong status; on a symlink evenENOENTdid. TheInvalbranch also wrapped the nilStaterror instead of the readlink error, so the cause never reached the server log.Fix
statusFromError(errors.go) names an error by its errno, and by theossentinels that filesystems not backed by the OS return instead. It reports whether it found a status at all, so the caller picks the fallback — what a client will accept depends on the operation. Order matters in that ladder and is commented:syscall.Errnoreports bothEEXISTandENOTEMPTYasos.ErrExist, and bothEACCESandEPERMasos.ErrPermission, so the narrower errno is tested first. A ladder in the wrong order silently answers with the wider errno's status — which is what a naiveos.IsExistcheck would have done here.onRenameuses it forfs.Renameand for both parent stats.onReadLinktakes its status from the errno and keeps a stat only as a fallback for filesystems that report no errno at all — memfs reports "not a symlink" as an opaquefmt.Errorf, and that path still yieldsNFS3ERR_INVAL. That fallback usesLstat, notStat:Statfollows the link and reports the target's mode, so reading the symlink bit off it would call a genuine symlink the wrong file type (thanks @cursor — see the thread; fixed in 60396c6).Lstatis also already whatonLookup,onGetAttrandonSetAttruse to ask a node's own type. The wrapped cause is preserved on every path, including the fallbacks, so the server log keeps it even when the wire status is generic.statusFromWriteErroris left alone: WRITE's RFC error list is narrow, and widening it would let the write path answer with statuses a client does not expect from WRITE. The comment onstatusFromErrorsays so.One deliberate behaviour change
An error neither the errno ladder nor the
Lstatfallback can name now leaves READLINK asNFS3ERR_IOrather than theNFS3ERR_ACCESit used to be.ACCESon a genuineEIOis a lie;IOis what RFC 1813 lists for the unknown case. Everything the ladder can name no longer reachesIOat all, which is the point of the change — worth a look if anything downstream keys onACCEShere.Why this bites us
Our sandbox filesystem implements
Renamewithunix.Renameatrather thanos.Rename, so it surfaces the realENOTEMPTYandEISDIR. Those are exactly the errnos the old ladder flattened intoNFS3ERR_IO, which is where the unhelpful 500s downstream come from.Worth knowing for anyone reading the tests:
os.RenamedeliberatelyLstats the destination first and returnsEEXISTfor any existing directory there, so a filesystem built onos.Renamecan never reportENOTEMPTYorEISDIR— hence the two test functions below.Tests
Both handlers are covered end to end: the tests drive the real server and read the status word straight off the wire (
target.Call), since the client library translates a few statuses intoossentinels and collapses the rest.TestOnRenameStatus— a realosfsover a temp dir: what a Go-backed filesystem can actually report (EXIST,NOTDIR,INVALfor a directory into its own subtree,NOENT, plus a successful rename).TestOnRenameStatusFromErrno— a filesystem wrapper injecting each errno arenameat-based backend reports, through to the status on the wire.TestOnReadLinkStatusFromErrno— a wrapper injecting a readlink error on a name that really is a symlink, over memfs's own link-followingStat. That combination is the live risk: anything reading the symlink bit off aStatthere concludes the client asked READLINK of a non-symlink.TestOnReadLinkStatusWithoutErrno— plain memfs, so the fallback and the happy path are both still exercised.TestStatusFromError/TestStatusErrorFrom— the ladder's ordering, theossentinels, wrapped/LinkError/PathErrorforms, and that the cause survives the fallback.16 of the 21 subtests fail against
masterfor the intended reason; the 5 that pass are the deliberate unchanged-behaviour guards. Beyond what CI covers: green with-race,gofmtclean, andgo vet ./...clean cross-compiled for linux, darwin and windows on amd64 and arm64 (the errno constants used are defined on all three).Note on CI: the
Goworkflow (build, lint, test) had never run on this repo before this PR — workflows from.github/workflows/were disabled, as forks are by default, so only GitHub's managed code-scanning and Dependabot workflows ran here. It has since been enabled and now passes on these commits, lint and tests included.codeql-analysis.ymlremainsdisabled_fork, which looks deliberate: the repo uses CodeQL default setup, and the "Analyze (go)" check comes from that rather than from the workflow file.Portability upstream
Nothing here is fork-specific — it is written to cherry-pick cleanly onto
willscott/go-nfs.🤖 Generated with Claude Code
https://claude.ai/code/session_01AeywBLV66AeSH8WuDfvv7Q