fix onRename: reject a cross-file-system rename on fsid - #12
Conversation
09368e2 to
151cce0
Compare
arielshaqed
left a comment
There was a problem hiding this comment.
Not sure: why would these ever be different filesystems?
| // non-nil func fields are never DeepEqual. | ||
| func unwrapFS(fs billy.Filesystem) billy.Filesystem { | ||
| for { | ||
| type unwrapper interface{ Unwrap() billy.Filesystem } |
There was a problem hiding this comment.
Raise to a higher level - this is now an external interface, you expect Filesystems to implement it, and go-nfs clients need to implement it.
151cce0 to
29e1829
Compare
arielshaqed
left a comment
There was a problem hiding this comment.
This seems weird: we suddenly need to add a RO wrapper, some things need to be held by pointer, etc. Can you instead explore getting rid of reflect.DeepEqual?
-
It is not useful. Suppose I write a memory billy.Filesystem that consists of a map from paths to contents. I mount two different such filesystems using go-nfs in the same process on
/mnt/1/and on/mnt/2. Afterecho a > /mnt/1/x echo b > /mnt/2/x mv -f /mnt/1/x /mnt/2/x
what happens? The filesystems are reflect.DeepEqual (it does deep equality on maps). So go-nfs thinks it's not cross-filesystem. How would you even write such a Rename method on the first filesystem? How does it even know it needs to copy to the other (currently equal) filesystem?
-
It is not correct. Suppose I mount the same NFS filesystem on
/mnt/1/and on/mnt/2/. I would expect the kernel to report cross-filesystem renames.
I think we should understand why go-nfs even has this check.
| // same filesystem with reflect.DeepEqual, and non-nil func values are never | ||
| // deeply equal, so an inline func field would make every wrapped filesystem |
There was a problem hiding this comment.
I had to look up the godoc to understand "non-nil func values are never deeply equal". Learn something new every day, I guess.
The deep issue is probably in the spec here:
Slice, map, and function types are not comparable. However, as a special case, a slice, map, or function value may be compared to the predeclared identifier nil
| // If h also implements nfs.DirIteratorHandler, the returned handler will too. | ||
| func RecoverPanics(h nfs.Handler, onPanic OnPanic) nfs.Handler { | ||
| base := &recoveryHandler{handler: h, onPanic: onPanic} | ||
| base := &recoveryHandler{handler: h, onPanic: &panicRef{fn: onPanic}} |
There was a problem hiding this comment.
Not sure what this does: different calls to RecoverPanics will create different pointers. So this only works if RecoverPanics is called once. This all makes sense and I am happy with it; the only issue is that the documentation of panicRef is really confusing: it stresses using a pointer to the same panicRef each time when in fact the point is to make it comparable.
| } | ||
| } | ||
|
|
||
| // readOnlyFS reports only ReadCapability, unlike billy.DefaultCapabilities. |
| // A write to a read-only target is not an I/O failure, and reporting it | ||
| // as one leaves the client with a misleading EIO. | ||
| if errors.Is(err, syscall.EROFS) { | ||
| return &NFSStatusError{NFSStatusROFS, err} | ||
| } |
There was a problem hiding this comment.
Is this only within onRename, and not in other funcs?
29e1829 to
e70f51e
Compare
5be31d8 to
49f0d65
Compare
recoveryFilesystem delegates explicitly rather than embedding, so it did not satisfy billy.Capable and billy.CapabilityCheck fell back to billy.DefaultCapabilities. That reports a read-only filesystem as writable, and the write operations gate on it.
49f0d65 to
2932c4f
Compare
835ca53 to
7181caf
Compare
arielshaqed
left a comment
There was a problem hiding this comment.
Thanks! This is a good way to go, I just wish we could stay backwards-compatible with go-nfs.
| // check the two fs are the same | ||
| if !reflect.DeepEqual(fs, fs2) { | ||
| return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} | ||
| } | ||
|
|
There was a problem hiding this comment.
This breaks backwards compatibility. I think we should not break, at least not yet.
7181caf to
466afcf
Compare
AliRamberg
left a comment
There was a problem hiding this comment.
Pushed the fsid-with-fallback version. Replies inline on the three open threads.
466afcf to
b1ebc5d
Compare
| if fromDirAttr.FSID != toDirAttr.FSID { | ||
| return &NFSStatusError{NFSStatusXDev, os.ErrInvalid} | ||
| } | ||
| if !bytes.Equal(userHandle.ToHandle(fs, nil), userHandle.ToHandle(fs2, nil)) { |
There was a problem hiding this comment.
Fallback as you asked: fsid decides only when it differs. Zero on both sides decides nothing, so a Filesystem that reports no fsid keeps returning NFSStatusNotSupp exactly as it does today.
It asks the handler for the root handle rather than comparing the filesystems directly, because recoveryHandler.ToHandle already unwraps *recoveryFilesystem -- so this needs no new type and no change to the wrappers. Verified: swapping it for a plain fs != fs2 makes a same-directory rename through RecoverPanics fail with NOTSUPP.
There was a problem hiding this comment.
Not sure why "RecoverPanics" is relevant here, but I don't think it affects this reply either.
This still changes behaviour of existing go-nfs - e.g. consider a filesystem type that returns the same handle for root. I don't know, maybe always "Find your dream.". We can probably retain the previous behaviour with something like
| if !bytes.Equal(userHandle.ToHandle(fs, nil), userHandle.ToHandle(fs2, nil)) { | |
| if fromDirAttr.FSID == 0 && !reflect.DeepEqual(fs, fs2) { |
The fact that it passed for a specially-crafted test does not mean that it will always pass.
| // operating-system backing reports one by returning a FileInfo from | ||
| // os.FileInfo.Sys(); leaving it zero makes every file appear to share a | ||
| // single file system. | ||
| FSID uint64 |
There was a problem hiding this comment.
Reworded rather than switched to *uint64. With the fallback, zero no longer means "same file system, allow" -- it means fsid decided nothing and the handle comparison runs instead, so the backwards-compatibility problem you flagged goes away without a pointer. Everest returns either nil or a FileInfo with no FSID from Sys(), so it is unaffected either way.
Still happy to switch to *uint64, or move it onto the Filesystem, if you would rather have an explicit absent.
There was a problem hiding this comment.
Thanks, this makes a lot of sense. Indeed the Linux exports manpage gives fsid 0 a very special meaning. Requiring the mounted filesystem to report something different makes a lot of sense.
arielshaqed
left a comment
There was a problem hiding this comment.
Thanks, this is great! I would still like to keep the exact earlier behaviour when fsid == 0. See inline - I think it's easy to do. To be clear, if it is hard then let's not.
| // operating-system backing reports one by returning a FileInfo from | ||
| // os.FileInfo.Sys(); leaving it zero makes every file appear to share a | ||
| // single file system. | ||
| FSID uint64 |
There was a problem hiding this comment.
Thanks, this makes a lot of sense. Indeed the Linux exports manpage gives fsid 0 a very special meaning. Requiring the mounted filesystem to report something different makes a lot of sense.
| if fromDirAttr.FSID != toDirAttr.FSID { | ||
| return &NFSStatusError{NFSStatusXDev, os.ErrInvalid} | ||
| } | ||
| if !bytes.Equal(userHandle.ToHandle(fs, nil), userHandle.ToHandle(fs2, nil)) { |
There was a problem hiding this comment.
Not sure why "RecoverPanics" is relevant here, but I don't think it affects this reply either.
This still changes behaviour of existing go-nfs - e.g. consider a filesystem type that returns the same handle for root. I don't know, maybe always "Find your dream.". We can probably retain the previous behaviour with something like
| if !bytes.Equal(userHandle.ToHandle(fs, nil), userHandle.ToHandle(fs2, nil)) { | |
| if fromDirAttr.FSID == 0 && !reflect.DeepEqual(fs, fs2) { |
The fact that it passed for a specially-crafted test does not mean that it will always pass.
RFC 1813 3.3.14 defines "to.dir and from.dir must reside on the same file system on the server" as the fsid fields in the two directories' attributes being equal, and requires NFS3ERR_XDEV when they differ. onRename only compared the filesystems that FromHandle returned, which answers a different question and rejected every rename made through helpers.RecoverPanics: it returns a fresh wrapper per call, and reflect.DeepEqual reports two non-nil func values as unequal. Compare fsid first. ToFileAttribute already reads the rest of the platform stat data and dropped st_dev alone, so carry it through as file.FileInfo.FSID; a Filesystem with no operating-system backing reports one by returning a file.FileInfo from os.FileInfo.Sys(). Zero reports nothing rather than a file system, so fall back to asking the handler that minted the handles, which compares the same filesystem equal to itself through any wrapper. Stat the destination through the filesystem that to.Handle named rather than through the source's. Reading both attributes through one filesystem compares it against itself.
b1ebc5d to
cc8e349
Compare
| if fromDirAttr.FSID != toDirAttr.FSID { | ||
| return &NFSStatusError{NFSStatusXDev, os.ErrInvalid} | ||
| } | ||
| if fromDirAttr.FSID == 0 && !reflect.DeepEqual(fs, fs2) { |
There was a problem hiding this comment.
Applied -- and you're right the root handle isn't an identity: Everest's ToHandle ignores its fs and always returns that constant.
RecoverPanics matters only as the consequence: a filesystem reporting no fsid lands in DeepEqual, which is false for its per-call wrapper, so every rename through it stays refused as on master -- the fix reaches only filesystems that report an fsid, which is why the test now does.
RFC 1813 3.3.14 defines "same file system" as the two directories'
fsidfields being equal and requiresNFS3ERR_XDEVwhen they differ.onRenamecompared the filesystemsFromHandlereturned instead — a different question, and one that rejected every rename throughhelpers.RecoverPanics, which hands out a fresh wrapper per call.ToFileAttributealready reads the platform stat data and droppedst_devalone, so this carries it through asfile.FileInfo.FSID. Zero reports nothing rather than a file system, so it falls back to the handler's root handle, which compares a filesystem equal to itself through any wrapper. The destination is now stat'd through the filesystemto.Handlenamed, not the source's.Unrelated fix, own commit:
recoveryFilesystemdelegates explicitly, so it never satisfiedbilly.Capable.CapabilityCheckfell back toDefaultCapabilities, reporting a read-only filesystem as writable.