diff options
| -rw-r--r-- | internal/fusefrontend/node_helpers.go | 4 | ||||
| -rw-r--r-- | internal/fusefrontend/statx_linux.go | 78 | ||||
| -rw-r--r-- | internal/syscallcompat/sys_linux.go | 9 | ||||
| -rw-r--r-- | tests/matrix/statx_linux_test.go | 156 |
4 files changed, 245 insertions, 2 deletions
diff --git a/internal/fusefrontend/node_helpers.go b/internal/fusefrontend/node_helpers.go index 96c0961..3102275 100644 --- a/internal/fusefrontend/node_helpers.go +++ b/internal/fusefrontend/node_helpers.go @@ -53,9 +53,9 @@ func (n *Node) readlink(dirfd int, cName string) (out []byte, errno syscall.Errn return []byte(target), 0 } -// translateSize translates the ciphertext size in `out` into plaintext size. +// translateSize translates the ciphertext size cSize into plaintext size. // Handles regular files & symlinks (and finds out what is what by looking at -// `out.Mode`). +// mode). func (n *Node) translateSize(dirfd int, cName string, mode uint32, cSize uint64) (pSize uint64) { switch mode & syscall.S_IFMT { case syscall.S_IFREG: diff --git a/internal/fusefrontend/statx_linux.go b/internal/fusefrontend/statx_linux.go new file mode 100644 index 0000000..f9f87cf --- /dev/null +++ b/internal/fusefrontend/statx_linux.go @@ -0,0 +1,78 @@ +package fusefrontend + +import ( + "context" + "syscall" + + "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" + "golang.org/x/sys/unix" + + "github.com/rfjakob/gocryptfs/v2/internal/inomap" + "github.com/rfjakob/gocryptfs/v2/internal/syscallcompat" +) + +var _ = (fs.NodeStatxer)((*Node)(nil)) +var _ = (fs.FileStatxer)((*File)(nil)) + +// Statx is the Linux statx equivalent of Getattr. +func (n *Node) Statx(ctx context.Context, f fs.FileHandle, flags uint32, mask uint32, out *fuse.StatxOut) (errno syscall.Errno) { + // If the kernel gives us a file handle, use it. Current Linux kernels do + // not send one with FUSE_STATX, but keep this for future compatibility. + if f != nil { + if fsx, ok := f.(fs.FileStatxer); ok { + return fsx.Statx(ctx, flags, mask, out) + } + } + + dirfd, cName, errno := n.prepareAtSyscallMyself() + if errno != 0 { + return errno + } + defer syscall.Close(dirfd) + + var st unix.Statx_t + err := syscallcompat.Statx(dirfd, cName, int(flags)|unix.AT_SYMLINK_NOFOLLOW, int(mask), &st) + if err != nil { + return fs.ToErrno(err) + } + + // fix inode number, size, owner + rn := n.rootNode() + st.Ino = rn.inoMap.Translate(inomap.NewQIno(unix.Mkdev(st.Dev_major, st.Dev_minor), 0, st.Ino)) + st.Size = rn.translateSize(dirfd, cName, uint32(st.Mode), st.Size) + if rn.args.ForceOwner != nil { + st.Uid = rn.args.ForceOwner.Uid + st.Gid = rn.args.ForceOwner.Gid + } + + out.FromStatx(&st) + return 0 +} + +// Statx returns statx information for an open backing file. Current Linux +// kernels do not send a file handle with FUSE_STATX, so this is not reached yet. +func (f *File) Statx(_ context.Context, flags uint32, mask uint32, out *fuse.StatxOut) syscall.Errno { + f.fdLock.RLock() + defer f.fdLock.RUnlock() + + var st unix.Statx_t + err := syscallcompat.Statx(f.intFd(), "", int(flags)|unix.AT_EMPTY_PATH, int(mask), &st) + if err != nil { + return fs.ToErrno(err) + } + + // fix inode number, size, owner + rn := f.rootNode + st.Ino = rn.inoMap.Translate(inomap.NewQIno(unix.Mkdev(st.Dev_major, st.Dev_minor), 0, st.Ino)) + if uint32(st.Mode)&syscall.S_IFMT == syscall.S_IFREG { + st.Size = rn.contentEnc.CipherSizeToPlainSize(st.Size) + } + if rn.args.ForceOwner != nil { + st.Uid = rn.args.ForceOwner.Uid + st.Gid = rn.args.ForceOwner.Gid + } + + out.FromStatx(&st) + return 0 +} diff --git a/internal/syscallcompat/sys_linux.go b/internal/syscallcompat/sys_linux.go index 2b8a6f7..ffa5a97 100644 --- a/internal/syscallcompat/sys_linux.go +++ b/internal/syscallcompat/sys_linux.go @@ -80,6 +80,15 @@ func Mknodat(dirfd int, path string, mode uint32, dev int) (err error) { return syscall.Mknodat(dirfd, path, mode, dev) } +// Statx wraps the Statx syscall. +// Retries on EINTR. +func Statx(dirfd int, path string, flags int, mask int, st *unix.Statx_t) (err error) { + err = retryEINTR(func() error { + return unix.Statx(dirfd, path, flags, mask, st) + }) + return err +} + // Dup3 wraps the Dup3 syscall. We want to use Dup3 rather than Dup2 because Dup2 // is not implemented on arm64. func Dup3(oldfd int, newfd int, flags int) (err error) { diff --git a/tests/matrix/statx_linux_test.go b/tests/matrix/statx_linux_test.go new file mode 100644 index 0000000..4d62663 --- /dev/null +++ b/tests/matrix/statx_linux_test.go @@ -0,0 +1,156 @@ +package matrix + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" + + "github.com/rfjakob/gocryptfs/v2/ctlsock" + "github.com/rfjakob/gocryptfs/v2/tests/test_helpers" +) + +const testStatxMask = unix.STATX_BASIC_STATS | unix.STATX_BTIME + +func statxAt(t *testing.T, dirfd int, path string, flags int) unix.Statx_t { + t.Helper() + var st unix.Statx_t + if err := unix.Statx(dirfd, path, flags, testStatxMask, &st); err != nil { + t.Fatal(err) + } + return st +} + +func encryptedPath(t *testing.T, plainPath string) string { + t.Helper() + resp := test_helpers.QueryCtlSock(t, ctlsockPath, ctlsock.RequestStruct{ + EncryptPath: plainPath, + }) + if resp.Result == "" { + t.Fatal(resp) + } + return filepath.Join(test_helpers.DefaultCipherDir, resp.Result) +} + +func requireFuseStatx(t *testing.T) { + t.Helper() + data, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + t.Fatal(err) + } + parts := strings.SplitN(strings.TrimSpace(string(data)), ".", 3) + if len(parts) < 2 { + t.Skipf("cannot parse kernel release %q", data) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + t.Skipf("cannot parse kernel release %q: %v", data, err) + } + minorString := parts[1] + if i := strings.IndexFunc(minorString, func(r rune) bool { + return r < '0' || r > '9' + }); i >= 0 { + minorString = minorString[:i] + } + minor, err := strconv.Atoi(minorString) + if err != nil { + t.Skipf("cannot parse kernel release %q: %v", data, err) + } + if major < 6 || major == 6 && minor < 6 { + t.Skip("FUSE_STATX requires Linux 6.6 or newer") + } +} + +func checkBtime(t *testing.T, plainPath string, cipherPath string, flags int) unix.Statx_t { + t.Helper() + cipherSt := statxAt(t, unix.AT_FDCWD, cipherPath, flags) + if cipherSt.Mask&unix.STATX_BTIME == 0 { + t.Skip("backing filesystem does not report STATX_BTIME") + } + plainSt := statxAt(t, unix.AT_FDCWD, plainPath, flags) + if plainSt.Mask&unix.STATX_BTIME == 0 { + t.Fatalf("mounted filesystem did not report STATX_BTIME: mask=%#x", plainSt.Mask) + } + if plainSt.Btime.Sec != cipherSt.Btime.Sec || plainSt.Btime.Nsec != cipherSt.Btime.Nsec { + t.Errorf("birth time mismatch: plain=%d.%09d cipher=%d.%09d", + plainSt.Btime.Sec, plainSt.Btime.Nsec, + cipherSt.Btime.Sec, cipherSt.Btime.Nsec) + } + return plainSt +} + +func TestStatxBtime(t *testing.T) { + requireFuseStatx(t) + + t.Run("root", func(t *testing.T) { + checkBtime(t, test_helpers.DefaultPlainDir, test_helpers.DefaultCipherDir, unix.AT_SYMLINK_NOFOLLOW) + }) + + t.Run("regular", func(t *testing.T) { + const content = "statx birth time" + relPath := strings.ReplaceAll(t.Name(), "/", "_") + plainPath := filepath.Join(test_helpers.DefaultPlainDir, relPath) + if err := os.WriteFile(plainPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + cipherPath := encryptedPath(t, relPath) + + before := checkBtime(t, plainPath, cipherPath, unix.AT_SYMLINK_NOFOLLOW) + if before.Size != uint64(len(content)) { + t.Errorf("wrong plaintext size: have=%d want=%d", before.Size, len(content)) + } + + // Check user-visible AT_EMPTY_PATH behavior. Current Linux kernels do + // not send the file handle in FUSE_STATX, so this still reaches + // Node.Statx rather than File.Statx. + f, err := os.Open(plainPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + fdSt := statxAt(t, int(f.Fd()), "", unix.AT_EMPTY_PATH) + if fdSt.Mask&unix.STATX_BTIME == 0 { + t.Fatalf("statx on open file did not report STATX_BTIME: mask=%#x", fdSt.Mask) + } + if fdSt.Btime.Sec != before.Btime.Sec || fdSt.Btime.Nsec != before.Btime.Nsec { + t.Errorf("statx on open file returned different birth time: path=%d.%09d fd=%d.%09d", + before.Btime.Sec, before.Btime.Nsec, fdSt.Btime.Sec, fdSt.Btime.Nsec) + } + + now := time.Now().Add(-time.Hour) + if err := os.Chtimes(plainPath, now, now); err != nil { + t.Fatal(err) + } + after := checkBtime(t, plainPath, cipherPath, unix.AT_SYMLINK_NOFOLLOW) + if after.Btime.Sec != before.Btime.Sec || after.Btime.Nsec != before.Btime.Nsec { + t.Errorf("birth time changed with mtime: before=%d.%09d after=%d.%09d", + before.Btime.Sec, before.Btime.Nsec, after.Btime.Sec, after.Btime.Nsec) + } + }) + + t.Run("directory", func(t *testing.T) { + relPath := strings.ReplaceAll(t.Name(), "/", "_") + plainPath := filepath.Join(test_helpers.DefaultPlainDir, relPath) + if err := os.Mkdir(plainPath, 0700); err != nil { + t.Fatal(err) + } + checkBtime(t, plainPath, encryptedPath(t, relPath), unix.AT_SYMLINK_NOFOLLOW) + }) + + t.Run("symlink", func(t *testing.T) { + const target = "/target/does/not/exist" + relPath := strings.ReplaceAll(t.Name(), "/", "_") + plainPath := filepath.Join(test_helpers.DefaultPlainDir, relPath) + if err := os.Symlink(target, plainPath); err != nil { + t.Fatal(err) + } + st := checkBtime(t, plainPath, encryptedPath(t, relPath), unix.AT_SYMLINK_NOFOLLOW) + if st.Size != uint64(len(target)) { + t.Errorf("wrong symlink size: have=%d want=%d", st.Size, len(target)) + } + }) +} |
