aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml2
-rw-r--r--Documentation/MANPAGE.md26
-rw-r--r--Documentation/performance.txt1
-rw-r--r--cli_args.go3
-rw-r--r--cli_args_test.go12
-rw-r--r--internal/fusefrontend/file_allocate_truncate.go2
-rw-r--r--internal/fusefrontend/node.go2
-rw-r--r--internal/fusefrontend/node_helpers.go4
-rw-r--r--internal/fusefrontend/node_prepare_syscall.go4
-rw-r--r--internal/fusefrontend/statx_linux.go78
-rw-r--r--internal/fusefrontend_reverse/root_node.go8
-rw-r--r--internal/nametransform/diriv.go18
-rw-r--r--internal/syscallcompat/sys_linux.go9
-rw-r--r--mount.go8
-rw-r--r--tests/cli/cli_test.go19
-rw-r--r--tests/defaults/main_test.go35
-rw-r--r--tests/matrix/main_test.go1
-rw-r--r--tests/matrix/statx_linux_test.go156
18 files changed, 372 insertions, 16 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a407f7..ac0c358 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0 # Make "git describe" work
- name: Install Go ${{ matrix.go }}
- uses: actions/setup-go@v6
+ uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go }}
diff --git a/Documentation/MANPAGE.md b/Documentation/MANPAGE.md
index 3ef6500..e306cf3 100644
--- a/Documentation/MANPAGE.md
+++ b/Documentation/MANPAGE.md
@@ -386,6 +386,32 @@ Only applicable to reverse mode.
Limitation: Mounted single files (yes this is possible) are NOT hidden.
+#### -readdirplus
+Enable FUSE `READDIRPLUS` on Linux. It is disabled by default, so
+attributes are fetched only when an application requests them.
+
+`READDIRPLUS` requests attributes for every entry during a directory
+listing. This can improve metadata-heavy workloads such as `ls -l`,
+but adds unnecessary work to names-only listings. With the default
+cache timeouts (without `-sharedstorage`), enabling `READDIRPLUS` made
+metadata-heavy listings 1.36x faster in a local 100,000-file directory
+and 1.82x faster in a single run over a 1,000,000-file NFS directory.
+It made names-only listings 6.5x and 18.2x slower respectively.
+
+The default also applies to reverse mode. Backup and synchronization
+tools that inspect metadata for most entries may benefit from
+`-readdirplus`.
+
+This option can also be combined with `-sharedstorage`. Because that
+mode disables kernel attribute caching, whether `READDIRPLUS` helps
+depends on the workload and backing storage.
+
+On platforms other than Linux, this option is accepted but has no
+effect.
+
+For benchmarks and more details, see
+https://github.com/rfjakob/gocryptfs/issues/1026 .
+
#### -rw, -ro
Mount the filesystem read-write (`-rw`, default) or read-only (`-ro`).
If both are specified, `-ro` takes precedence.
diff --git a/Documentation/performance.txt b/Documentation/performance.txt
index 7e964e0..1d5d12a 100644
--- a/Documentation/performance.txt
+++ b/Documentation/performance.txt
@@ -77,6 +77,7 @@ v2.0.1-28-g49507ea 335 951 10.2 5.4 4.1 2.0 go1.25.4, Linux 6.
v2.6.1-22-gbc94538 432 950 10.0 5.4 3.8 2.0
v2.6.1-24-gb239d51 426 941 9.9 5.5 3.7 2.0 go-fuse v2.9.0
v2.6.1-26-g700432e 461 962 9.8 5.4 2.0 2.0
+v2.6.1-71-g5b2881e7 449 964 10.5 5.6 2.4 2.6 use READDIR by default
Results for EncFS for comparison (benchmark.bash -encfs):
diff --git a/cli_args.go b/cli_args.go
index 707b453..52061e3 100644
--- a/cli_args.go
+++ b/cli_args.go
@@ -29,7 +29,7 @@ type argContainer struct {
debug, init, zerokey, fusedebug, openssl, passwd, fg, version,
plaintextnames, quiet, nosyslog, wpanic,
longnames, allow_other, reverse, aessiv, nonempty, raw64,
- noprealloc, speed, hkdf, serialize_reads, hh, info,
+ noprealloc, readdirplus, speed, hkdf, serialize_reads, hh, info,
sharedstorage, fsck, one_file_system, deterministic_names,
xchacha, noxattr bool
// Mount options with opposites
@@ -178,6 +178,7 @@ func parseCliOpts(osArgs []string) (args argContainer) {
flagSet.BoolVar(&args.nonempty, "nonempty", false, "Allow mounting over non-empty directories")
flagSet.BoolVar(&args.raw64, "raw64", true, "Use unpadded base64 for file names")
flagSet.BoolVar(&args.noprealloc, "noprealloc", false, "Disable preallocation before writing")
+ flagSet.BoolVar(&args.readdirplus, "readdirplus", false, "Enable FUSE READDIRPLUS (Linux only)")
flagSet.BoolVar(&args.speed, "speed", false, "Run crypto speed test")
flagSet.BoolVar(&args.hkdf, "hkdf", true, "Use HKDF as an additional key derivation step")
flagSet.BoolVar(&args.serialize_reads, "serialize_reads", false, "Try to serialize read operations")
diff --git a/cli_args_test.go b/cli_args_test.go
index 4fb01ac..d7e28ab 100644
--- a/cli_args_test.go
+++ b/cli_args_test.go
@@ -157,6 +157,18 @@ func TestParseCliOpts(t *testing.T) {
}...)
o = defaultArgs
+ o.readdirplus = true
+ testcases = append(testcases, []testcaseContainer{
+ {
+ i: []string{"gocryptfs", "-readdirplus"},
+ o: o,
+ }, {
+ i: []string{"gocryptfs", "-o", "readdirplus"},
+ o: o,
+ },
+ }...)
+
+ o = defaultArgs
o.exclude = []string{"foo", "bar", "baz,boe"}
testcases = append(testcases, []testcaseContainer{
{
diff --git a/internal/fusefrontend/file_allocate_truncate.go b/internal/fusefrontend/file_allocate_truncate.go
index bfd11e1..f4a078c 100644
--- a/internal/fusefrontend/file_allocate_truncate.go
+++ b/internal/fusefrontend/file_allocate_truncate.go
@@ -97,6 +97,8 @@ func (f *File) Allocate(ctx context.Context, off uint64, sz uint64, mode uint32)
}
// truncate - called from node.Setattr and file.Setattr.
+//
+// The caller must hold f.fileTableEntry.ContentLock
func (f *File) truncate(newSize uint64) (errno syscall.Errno) {
var err error
// Common case first: Truncate to zero
diff --git a/internal/fusefrontend/node.go b/internal/fusefrontend/node.go
index 28ebbd5..59f6b2f 100644
--- a/internal/fusefrontend/node.go
+++ b/internal/fusefrontend/node.go
@@ -246,6 +246,8 @@ func (n *Node) Setattr(ctx context.Context, f fs.FileHandle, in *fuse.SetAttrIn,
}
f2 := f.(*File)
defer f2.Release(ctx)
+ f2.fileTableEntry.ContentLock.Lock()
+ defer f2.fileTableEntry.ContentLock.Unlock()
errno = syscall.Errno(f2.truncate(sz))
if errno != 0 {
return errno
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/node_prepare_syscall.go b/internal/fusefrontend/node_prepare_syscall.go
index 9021350..03194df 100644
--- a/internal/fusefrontend/node_prepare_syscall.go
+++ b/internal/fusefrontend/node_prepare_syscall.go
@@ -3,6 +3,7 @@ package fusefrontend
import (
"syscall"
+ "github.com/rfjakob/gocryptfs/v2/internal/nametransform"
"github.com/rfjakob/gocryptfs/v2/internal/tlog"
"github.com/hanwen/go-fuse/v2/fs"
@@ -73,8 +74,9 @@ func (n *Node) prepareAtSyscall(child string) (dirfd int, cName string, errno sy
var err error
iv, err = rn.nameTransform.ReadDirIVAt(dirfd)
if err != nil {
+ tlog.Warn.Printf("prepareAtSyscall: could not read %s: %v", nametransform.DirIVFilename, err)
syscall.Close(dirfd)
- return -1, "", fs.ToErrno(err)
+ return -1, "", syscall.EIO
}
}
rn.dirCache.Store(n, dirfd, iv)
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/fusefrontend_reverse/root_node.go b/internal/fusefrontend_reverse/root_node.go
index 7ac28af..461b25c 100644
--- a/internal/fusefrontend_reverse/root_node.go
+++ b/internal/fusefrontend_reverse/root_node.go
@@ -68,7 +68,7 @@ func NewRootNode(args fusefrontend.Args, c *contentenc.ContentEnc, n *nametransf
var rootDev uint64
var st syscall.Stat_t
var statErr error
- var shortNameMax int
+ var shortNameMax = syscall.NAME_MAX
if statErr = syscall.Stat(args.Cipherdir, &st); statErr != nil {
tlog.Warn.Printf("Could not stat backing directory %q: %v", args.Cipherdir, statErr)
if args.OneFileSystem {
@@ -79,8 +79,10 @@ func NewRootNode(args fusefrontend.Args, c *contentenc.ContentEnc, n *nametransf
rootDev = uint64(st.Dev)
}
- shortNameMax = n.GetLongNameMax() * 3 / 4
- shortNameMax = shortNameMax - shortNameMax%16 - 1
+ if !args.PlaintextNames {
+ shortNameMax = n.GetLongNameMax() * 3 / 4
+ shortNameMax = shortNameMax - shortNameMax%16 - 1
+ }
rn := &RootNode{
args: args,
diff --git a/internal/nametransform/diriv.go b/internal/nametransform/diriv.go
index 5dd4940..aaa65ee 100644
--- a/internal/nametransform/diriv.go
+++ b/internal/nametransform/diriv.go
@@ -83,16 +83,22 @@ func WriteDirIVAt(dirfd int) error {
if !syscallcompat.IsENOSPC(err) {
tlog.Warn.Printf("WriteDirIV: Write: %v", err)
}
- // Delete incomplete gocryptfs.diriv file
- syscallcompat.Unlinkat(dirfd, DirIVFilename, 0)
- return err
+ goto delete
+ }
+ err = f.Sync()
+ if err != nil {
+ tlog.Warn.Printf("WriteDirIV: Sync: %v", err)
+ goto delete
}
err = f.Close()
if err != nil {
tlog.Warn.Printf("WriteDirIV: Close: %v", err)
- // Delete incomplete gocryptfs.diriv file
- syscallcompat.Unlinkat(dirfd, DirIVFilename, 0)
- return err
+ goto delete
}
return nil
+
+delete:
+ // Delete potentially incomplete gocryptfs.diriv file
+ syscallcompat.Unlinkat(dirfd, DirIVFilename, 0)
+ return err
}
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/mount.go b/mount.go
index 4b78be7..489ae31 100644
--- a/mount.go
+++ b/mount.go
@@ -330,8 +330,11 @@ func initFuseFrontend(args *argContainer) (rootNode fs.InodeEmbedder, wipeKeys f
// Init crypto backend
cCore := cryptocore.New(masterkey, cryptoBackend, IVBits, args.hkdf)
cEnc := contentenc.New(cCore, contentenc.DefaultBS)
- nameTransform := nametransform.New(cCore.EMECipher, frontendArgs.LongNames, args.longnamemax,
- args.raw64, []string(args.badname), frontendArgs.DeterministicNames)
+ var nameTransform *nametransform.NameTransform
+ if !args.plaintextnames {
+ nameTransform = nametransform.New(cCore.EMECipher, frontendArgs.LongNames, args.longnamemax,
+ args.raw64, []string(args.badname), frontendArgs.DeterministicNames)
+ }
// After the crypto backend is initialized,
// we can purge the master key from memory.
for i := range masterkey {
@@ -390,6 +393,7 @@ func initGoFuse(rootNode fs.InodeEmbedder, args *argContainer) *fuse.Server {
// Enable go-fuse warnings
fuseOpts.Logger = log.New(os.Stderr, "go-fuse: ", log.Lmicroseconds)
fuseOpts.MountOptions = fuse.MountOptions{
+ DisableReadDirPlus: !args.readdirplus,
// Writes and reads are usually capped at 128kiB on Linux through
// the FUSE_MAX_PAGES_PER_REQ kernel constant in fuse_i.h. Our
// sync.Pool buffer pools are sized acc. to the default. Users may set
diff --git a/tests/cli/cli_test.go b/tests/cli/cli_test.go
index 01cc3b7..472941d 100644
--- a/tests/cli/cli_test.go
+++ b/tests/cli/cli_test.go
@@ -991,6 +991,25 @@ func TestInitNotEmpty(t *testing.T) {
}
}
+// TestReaddirplus checks that mounting and listing work with -readdirplus.
+func TestReaddirplus(t *testing.T) {
+ dir := test_helpers.InitFS(t)
+ mnt := dir + ".mnt"
+ test_helpers.MountOrFatal(t, dir, mnt, "-extpass=echo test", "-readdirplus")
+ defer test_helpers.UnmountPanic(mnt)
+
+ if err := os.WriteFile(mnt+"/file", nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(mnt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 || entries[0].Name() != "file" {
+ t.Fatalf("unexpected directory entries: %v", entries)
+ }
+}
+
// TestSharedstorage checks that `-sharedstorage` shows stable inode numbers to
// userspace despite having hard link tracking disabled
func TestSharedstorage(t *testing.T) {
diff --git a/tests/defaults/main_test.go b/tests/defaults/main_test.go
index a19f079..237dbd1 100644
--- a/tests/defaults/main_test.go
+++ b/tests/defaults/main_test.go
@@ -554,3 +554,38 @@ func TestSeekDir(t *testing.T) {
t.Error("Seek did not have any effect")
}
}
+
+// Regression test for https://github.com/rfjakob/gocryptfs/issues/1024
+//
+// truncate(2) goes through node.Setattr, which opens its own file handle. That
+// handle must take ContentLock like file.Setattr does: the lock doubles as the
+// global write-operation counter that isConsecutiveWrite() uses to notice that
+// somebody else changed the file. Without it, an already-open handle keeps
+// believing its next write appends, skips writePadHole(), and leaves the last
+// block short while the file grows past it - the block then fails to decrypt.
+func TestConcurrentTruncateViaPath(t *testing.T) {
+ path := test_helpers.DefaultPlainDir + "/" + t.Name()
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+
+ b := make([]byte, 4096)
+ _, err = f.Write(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Truncate via path used to not increment writeOpCount...
+ if err = os.Truncate(path, 8); err != nil {
+ t.Fatal(err)
+ }
+ // ...which means this write will not call writePadHole.
+ if _, err = f.Write([]byte("foo")); err != nil {
+ t.Fatal(err)
+ }
+ // First block is corrupt now.
+ if _, err = f.ReadAt(b, 0); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/tests/matrix/main_test.go b/tests/matrix/main_test.go
index cf0b6c4..126adaf 100644
--- a/tests/matrix/main_test.go
+++ b/tests/matrix/main_test.go
@@ -59,6 +59,7 @@ func TestMain(m *testing.M) {
{false, "auto", false, true, nil},
// -serialize_reads
{false, "auto", false, false, []string{"-serialize_reads"}},
+ {false, "auto", false, false, []string{"-readdirplus"}},
{false, "auto", false, false, []string{"-sharedstorage"}},
{false, "auto", false, false, []string{"-deterministic-names"}},
// Test xchacha with and without openssl
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))
+ }
+ })
+}