aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/cli/cli_test.go19
-rw-r--r--tests/cluster/cluster_test.go155
-rw-r--r--tests/cluster/poc_test.go230
-rw-r--r--tests/defaults/main_test.go35
-rw-r--r--tests/matrix/main_test.go1
-rw-r--r--tests/matrix/matrix_test.go15
-rw-r--r--tests/matrix/statx_linux_test.go156
-rw-r--r--tests/reverse/config_custom_test.go62
-rwxr-xr-xtests/stress_tests/pjdfstest.bash34
-rw-r--r--tests/test_helpers/helpers.go2
10 files changed, 690 insertions, 19 deletions
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/cluster/cluster_test.go b/tests/cluster/cluster_test.go
index af93bc4..70a2a02 100644
--- a/tests/cluster/cluster_test.go
+++ b/tests/cluster/cluster_test.go
@@ -7,44 +7,47 @@ package cluster_test
import (
"bytes"
+ "errors"
+ "io"
"math/rand"
"os"
"sync"
+ "syscall"
"testing"
"github.com/rfjakob/gocryptfs/v2/tests/test_helpers"
)
-// This test passes on XFS but fails on ext4 and tmpfs!!!
+// With -sharedstorage (i.e. with fcntl byte-range locks) this test passes on all
+// filesystems. Without, it passes on XFS but fails on ext4 and tmpfs.
//
// Quoting https://lists.samba.org/archive/samba-technical/2019-March/133050.html
//
// > It turns out that xfs respects POSIX w.r.t "atomic read/write" and
// > this is implemented by taking a file-wide shared lock on every
// > buffered read.
-// > This behavior is unique to XFS on Linux and is not optional.
-// > Other Linux filesystems only guaranty page level atomicity for
-// > buffered read/write.
+//
+// Note that ext4 actually provides NO ATOMICITY AT ALL.
+// Quoting https://stackoverflow.com/a/35256626 :
+//
+// > Linux 4.2.6 with ext4: update atomicity = 1 byte
+//
+// TestPoCTornWrite in this package confirms this.
//
// See also:
// - https://lore.kernel.org/linux-xfs/20190325001044.GA23020@dastard/
// Dave Chinner: XFS is the only linux filesystem that provides this behaviour.
func TestClusterConcurrentRW(t *testing.T) {
- if os.Getenv("ENABLE_CLUSTER_TEST") != "1" {
- t.Skipf("This test is disabled by default because it fails unless on XFS.\n" +
- "Run it like this: ENABLE_CLUSTER_TEST=1 go test\n" +
- "Choose a backing directory by setting TMPDIR.")
- }
-
- const blocksize = 4096
- const fileSize = 25 * blocksize // 100 kiB
+ const fileSize = 100000 // arbitrary unaligned size with a partial block at the end
+ const writeSize = 5000 // arbitrary unaligned size that touches two ciphertext blocks
+ const readSize = 5000
cDir := test_helpers.InitFS(t)
mnt1 := cDir + ".mnt1"
mnt2 := cDir + ".mnt2"
- test_helpers.MountOrFatal(t, cDir, mnt1, "-extpass=echo test", "-wpanic=0")
+ test_helpers.MountOrFatal(t, cDir, mnt1, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
defer test_helpers.UnmountPanic(mnt1)
- test_helpers.MountOrFatal(t, cDir, mnt2, "-extpass=echo test", "-wpanic=0")
+ test_helpers.MountOrFatal(t, cDir, mnt2, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
defer test_helpers.UnmountPanic(mnt2)
f1, err := os.Create(mnt1 + "/foo")
@@ -68,12 +71,12 @@ func TestClusterConcurrentRW(t *testing.T) {
const loops = 10000
writeThread := func(f *os.File) {
defer wg.Done()
- buf := make([]byte, blocksize)
+ buf := make([]byte, writeSize)
for i := 0; i < loops; i++ {
if t.Failed() {
return
}
- off := rand.Int63n(fileSize / blocksize)
+ off := rand.Int63n(int64(fileSize - len(buf) - 1))
_, err := f.WriteAt(buf, off)
if err != nil {
t.Errorf("writeThread iteration %d: WriteAt failed: %v", i, err)
@@ -83,13 +86,13 @@ func TestClusterConcurrentRW(t *testing.T) {
}
readThread := func(f *os.File) {
defer wg.Done()
- zeroBlock := make([]byte, blocksize)
- buf := make([]byte, blocksize)
+ zeroBlock := make([]byte, readSize)
+ buf := make([]byte, len(zeroBlock))
for i := 0; i < loops; i++ {
if t.Failed() {
return
}
- off := rand.Int63n(fileSize / blocksize)
+ off := rand.Int63n(int64(fileSize - len(zeroBlock) - 1))
_, err := f.ReadAt(buf, off)
if err != nil {
t.Errorf("readThread iteration %d: ReadAt failed: %v", i, err)
@@ -109,3 +112,117 @@ func TestClusterConcurrentRW(t *testing.T) {
go readThread(f2)
wg.Wait()
}
+
+// Multiple hosts creating the same file at the same time could
+// overwrite each other's file header, leading to data
+// corruption. Passing "-sharedstorage" should prevent this.
+func TestConcurrentCreate(t *testing.T) {
+ cDir := test_helpers.InitFS(t)
+ mnt1 := cDir + ".mnt1"
+ mnt2 := cDir + ".mnt2"
+ test_helpers.MountOrFatal(t, cDir, mnt1, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
+ defer test_helpers.UnmountPanic(mnt1)
+ test_helpers.MountOrFatal(t, cDir, mnt2, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
+ defer test_helpers.UnmountPanic(mnt2)
+
+ var wg sync.WaitGroup
+ const loops = 10000
+
+ createOrOpen := func(path string) (f *os.File, err error) {
+ // Use the high-level os.Create/OpenFile instead of syscall.Open because we
+ // *want* Go's EINTR retry logic. glibc open(2) has similar logic.
+ f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
+ if err == nil {
+ return
+ }
+ if !errors.Is(err, os.ErrExist) {
+ t.Logf("POSIX compliance issue: exclusive create failed with unexpected error: err=%v", errors.Unwrap(err))
+ }
+ f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
+ if err == nil {
+ return
+ }
+ t.Logf("POSIX compliance issue: non-exlusive create failed with err=%v", errors.Unwrap(err))
+ return
+ }
+
+ workerThread := func(path string) {
+ defer wg.Done()
+ buf := make([]byte, 10)
+ for i := 0; i < loops; i++ {
+ if t.Failed() {
+ return
+ }
+ f, err := createOrOpen(path)
+ if err != nil {
+ // retry
+ continue
+ }
+ defer f.Close()
+ _, err = f.WriteAt(buf, 0)
+ if err != nil {
+ t.Errorf("iteration %d: Pwrite: %v", i, err)
+ return
+ }
+ buf2 := make([]byte, len(buf)+1)
+ n, err := f.ReadAt(buf2, 0)
+ if err != nil && err != io.EOF {
+ t.Errorf("iteration %d: ReadAt: %v", i, err)
+ return
+ }
+ buf2 = buf2[:n]
+ if !bytes.Equal(buf, buf2) {
+ t.Errorf("iteration %d: corrupt data received: %x", i, buf2)
+ return
+ }
+ syscall.Unlink(path)
+
+ // Close now (not only when the whole loop exists) to avoid exhausting fds.
+ // Double-close on happy path is harmless: "Close will return an error if it has already been called"
+ f.Close()
+ }
+ }
+
+ wg.Add(2)
+ go workerThread(mnt1 + "/foo")
+ go workerThread(mnt2 + "/foo")
+ wg.Wait()
+}
+
+// Check that opening with O_CREATE|O_TRUNC and writing always works
+func TestOpenTruncate(t *testing.T) {
+ cDir := test_helpers.InitFS(t)
+ mnt1 := cDir + ".mnt1"
+ mnt2 := cDir + ".mnt2"
+ test_helpers.MountOrFatal(t, cDir, mnt1, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
+ defer test_helpers.UnmountPanic(mnt1)
+ test_helpers.MountOrFatal(t, cDir, mnt2, "-extpass=echo test", "-wpanic=0", "-sharedstorage")
+ defer test_helpers.UnmountPanic(mnt2)
+
+ var wg sync.WaitGroup
+ const loops = 100
+
+ writerThread := func(path string) {
+ defer wg.Done()
+ for i := 0; i < loops; i++ {
+ if t.Failed() {
+ return
+ }
+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
+ if err != nil {
+ t.Logf("POSIX compliance issue: non-exlusive create failed with err=%v", errors.Unwrap(err))
+ continue
+ }
+ _, err = f.WriteAt([]byte("foo"), 0)
+ if err != nil {
+ t.Errorf("iteration %d: WriteAt: %v", i, err)
+ }
+ f.Close()
+ }
+ }
+
+ wg.Add(2)
+ go writerThread(mnt1 + "/foo")
+ go writerThread(mnt2 + "/foo")
+ wg.Wait()
+}
diff --git a/tests/cluster/poc_test.go b/tests/cluster/poc_test.go
new file mode 100644
index 0000000..52b9ec9
--- /dev/null
+++ b/tests/cluster/poc_test.go
@@ -0,0 +1,230 @@
+package cluster
+
+// poc_test.go contains proof of concept tests for the byte-range locking logic.
+// This goes directly to an underlying filesystem without going through gocryptfs.
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "testing"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/rfjakob/gocryptfs/v2/internal/contentenc"
+ "github.com/rfjakob/gocryptfs/v2/internal/syscallcompat"
+ "github.com/rfjakob/gocryptfs/v2/tests/test_helpers"
+)
+
+// Check that byte-range locks work on an empty file
+func TestPoCFcntlFlock(t *testing.T) {
+ path := test_helpers.TmpDir + "/" + t.Name()
+
+ fd1, err := syscall.Open(path, syscall.O_CREAT|syscall.O_WRONLY|syscall.O_EXCL, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer syscall.Close(fd1)
+
+ // F_OFD_SETLK locks on the same fd always succeed, so we have to
+ // open a 2nd time.
+ fd2, err := syscall.Open(path, syscall.O_RDWR, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer syscall.Close(fd2)
+
+ lk := unix.Flock_t{
+ Type: unix.F_WRLCK,
+ Whence: unix.SEEK_SET,
+ Start: 0,
+ Len: 0,
+ }
+ err = unix.FcntlFlock(uintptr(fd1), syscallcompat.F_OFD_SETLK, &lk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = unix.FcntlFlock(uintptr(fd2), syscallcompat.F_OFD_SETLK, &lk)
+ if err == nil {
+ t.Fatal("double-lock succeeded but should have failed")
+ }
+}
+
+// See if we can get garbage data when the file header is read and written concurrently.
+// We should get either 0 bytes or 18 correct bytes.
+func TestPoCHeaderCreation(t *testing.T) {
+ path := test_helpers.TmpDir + "/" + t.Name()
+ var wg sync.WaitGroup
+ // I ran this with 10000 iteration and no problems to be seen. Let's not waste too
+ // much testing time.
+ const loops = 100
+
+ var stats struct {
+ readOk int64
+ readEmpty int64
+ writes int64
+ }
+
+ writeBuf := []byte("123456789012345678")
+ if len(writeBuf) != contentenc.HeaderLen {
+ t.Fatal("BUG wrong header length")
+ }
+
+ writerThread := func() {
+ defer wg.Done()
+ for i := 0; i < loops; i++ {
+ if t.Failed() {
+ return
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
+ if err != nil {
+ t.Errorf("BUG: this should not happen: open err=%v", err)
+ return
+ }
+ // Do like gocryptfs does and prealloc the 18 bytes
+ err = syscallcompat.EnospcPrealloc(int(f.Fd()), 0, contentenc.HeaderLen)
+ if err != nil {
+ t.Error(err)
+ }
+
+ _, err = f.WriteAt(writeBuf, 0)
+ if err != nil {
+ t.Errorf("iteration %d: Pwrite: %v", i, err)
+ }
+ atomic.AddInt64(&stats.writes, 1)
+ f.Close()
+ syscall.Unlink(path)
+ }
+ }
+
+ readerThread := func() {
+ defer wg.Done()
+ for i := 0; i < loops; i++ {
+ if t.Failed() {
+ return
+ }
+ f, err := os.OpenFile(path, os.O_RDONLY, 0600)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ readBuf := make([]byte, contentenc.HeaderLen)
+ _, err = f.ReadAt(readBuf, 0)
+ if errors.Is(err, io.EOF) {
+ atomic.AddInt64(&stats.readEmpty, 1)
+ goto close
+ }
+ if err != nil {
+ t.Errorf("iteration %d: ReadAt: %v", i, err)
+ goto close
+ }
+ if !bytes.Equal(writeBuf, readBuf) {
+ t.Errorf("iteration %d: corrupt data received: %x", i, readBuf)
+ goto close
+ }
+ atomic.AddInt64(&stats.readOk, 1)
+ close:
+ f.Close()
+ }
+ }
+
+ wg.Add(2)
+ go writerThread()
+ go readerThread()
+ wg.Wait()
+
+ t.Logf("readEmpty=%d readOk=%d writes=%d", stats.readEmpty, stats.readOk, stats.writes)
+}
+
+// TestPoCTornWrite simulates what TestConcurrentCreate does.
+//
+// Fails on ext4, quoting https://stackoverflow.com/a/35256626 :
+// > Linux 4.2.6 with ext4: update atomicity = 1 byte
+//
+// Passes on XFS.
+func TestPoCTornWrite(t *testing.T) {
+ if os.Getenv("ASSUME_XFS") != "1" {
+ t.Skipf("This test is disabled by default because it fails unless on XFS.\n" +
+ "Run it like this: ASSUME_XFS=1 go test -run TestPoCTornWrite\n" +
+ "Choose a backing directory by setting TMPDIR.")
+ }
+ doTestPoCTornWrite(t, false)
+}
+
+// Same as TestPoCTornWrite but uses fcntl byte range locks
+func TestPoCTornWriteLocked(t *testing.T) {
+ doTestPoCTornWrite(t, true)
+}
+
+func doTestPoCTornWrite(t *testing.T, locking bool) {
+ path := test_helpers.TmpDir + "/" + t.Name()
+ var wg sync.WaitGroup
+ const loops = 10000
+
+ writerThread := func() {
+ defer wg.Done()
+ for i := 0; i < loops; i++ {
+ if t.Failed() {
+ return
+ }
+
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ t.Errorf("BUG: this should not happen: open err=%v", err)
+ return
+ }
+
+ // Write
+ blockData := bytes.Repeat([]byte{byte(i)}, 42)
+ if locking {
+ lk := unix.Flock_t{
+ Type: unix.F_WRLCK,
+ Whence: unix.SEEK_SET,
+ Start: 0,
+ Len: int64(len(blockData)),
+ }
+ if err := unix.FcntlFlock(uintptr(f.Fd()), syscallcompat.F_OFD_SETLKW, &lk); err != nil {
+ t.Error(err)
+ return
+ }
+ // No need to unlock, lock is implicitely dropped on fd close
+ }
+ if _, err = f.WriteAt(blockData, 0); err != nil {
+ t.Errorf("iteration %d: WriteAt: %v", i, err)
+ return
+ }
+
+ // Readback and verify
+ readBuf := make([]byte, 100)
+ if n, err := f.ReadAt(readBuf, 0); err == io.EOF {
+ readBuf = readBuf[:n]
+ } else if err != nil {
+ t.Error(err)
+ return
+ }
+ if len(readBuf) != len(blockData) {
+ t.Error("wrong length")
+ return
+ }
+ for _, v := range readBuf {
+ if v != readBuf[0] {
+ t.Errorf("iteration %d: inconsistent block: %x", i, readBuf)
+ return
+ }
+ }
+ f.Close()
+ }
+ }
+
+ wg.Add(2)
+ go writerThread()
+ go writerThread()
+ wg.Wait()
+}
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/matrix_test.go b/tests/matrix/matrix_test.go
index a2ec549..2f097fb 100644
--- a/tests/matrix/matrix_test.go
+++ b/tests/matrix/matrix_test.go
@@ -993,3 +993,18 @@ func TestRenameExchangeOnGocryptfs(t *testing.T) {
t.Errorf("file2 content wrong after exchange. Expected: %s, Got: %s", content1, newContent2)
}
}
+
+func TestUnlinkedO_SYNC(t *testing.T) {
+ path := test_helpers.DefaultPlainDir + "/" + t.Name()
+ fd, err := syscall.Open(path, syscall.O_CREAT|syscall.O_RDWR|syscall.O_SYNC, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer syscall.Close(fd)
+ if err = os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = syscall.Write(fd, make([]byte, 10)); err != nil {
+ t.Error(err)
+ }
+}
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))
+ }
+ })
+}
diff --git a/tests/reverse/config_custom_test.go b/tests/reverse/config_custom_test.go
new file mode 100644
index 0000000..a7883af
--- /dev/null
+++ b/tests/reverse/config_custom_test.go
@@ -0,0 +1,62 @@
+package reverse_test
+
+import (
+ "os"
+ "os/exec"
+ "testing"
+
+ "github.com/rfjakob/gocryptfs/v2/tests/test_helpers"
+)
+
+// TestConfigCustomInsideCipherdir verifies that a custom config file (-config)
+// located inside CIPHERDIR is hidden from the encrypted reverse view instead of
+// leaking there in encrypted form.
+//
+// Regression test for https://github.com/rfjakob/gocryptfs/issues/1009
+func TestConfigCustomInsideCipherdir(t *testing.T) {
+ backingDir, err := os.MkdirTemp(test_helpers.TmpDir, t.Name()+".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A regular file that must stay visible in the encrypted view.
+ if err := os.WriteFile(backingDir+"/secret.txt", []byte("hello"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ // The custom config file lives *inside* the backing dir.
+ cfg := backingDir + "/my-custom.conf"
+
+ // Init reverse fs with the config at the custom location.
+ initArgs := []string{"-q", "-init", "-reverse", "-extpass", "echo test", "-scryptn=10", "-config", cfg}
+ if plaintextnames {
+ initArgs = append(initArgs, "-plaintextnames")
+ } else if deterministic_names {
+ initArgs = append(initArgs, "-deterministic-names")
+ }
+ initArgs = append(initArgs, backingDir)
+ cmd := exec.Command(test_helpers.GocryptfsBinary, initArgs...)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("init failed: %v", err)
+ }
+
+ mnt := backingDir + ".mnt"
+ sock := mnt + ".sock"
+ test_helpers.MountOrFatal(t, backingDir, mnt, "-reverse", "-extpass", "echo test",
+ "-config", cfg, "-ctlsock", sock)
+ defer test_helpers.UnmountPanic(mnt)
+
+ // The custom config file must NOT show up (encrypted) in the view.
+ cCfg := ctlsockEncryptPath(t, sock, "my-custom.conf")
+ if test_helpers.VerifyExistence(t, mnt+"/"+cCfg) {
+ t.Errorf("custom config %q is exposed in the encrypted view (as %q), but should be hidden", cfg, cCfg)
+ }
+ // With a custom config file, no virtual gocryptfs.conf is presented.
+ if test_helpers.VerifyExistence(t, mnt+"/gocryptfs.conf") {
+ t.Errorf("gocryptfs.conf should not be present in the view when -config is used")
+ }
+ // Regular files must remain visible.
+ cSecret := ctlsockEncryptPath(t, sock, "secret.txt")
+ if !test_helpers.VerifyExistence(t, mnt+"/"+cSecret) {
+ t.Errorf("regular file secret.txt (as %q) is missing from the encrypted view", cSecret)
+ }
+}
diff --git a/tests/stress_tests/pjdfstest.bash b/tests/stress_tests/pjdfstest.bash
new file mode 100755
index 0000000..a786e41
--- /dev/null
+++ b/tests/stress_tests/pjdfstest.bash
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Mount a gocryptfs filesystem in /var/tmp and run pjdfstest against it
+
+set -eu
+
+export TMPDIR=${TMPDIR:-/var/tmp}
+
+cd "$(dirname "$0")"
+MYNAME=$(basename "$0")
+source ../fuse-unmount.bash
+
+pjdfstest_dir=$(realpath ../../../pjdfstest)
+if [[ ! -x $pjdfstest_dir/pjdfstest ]]
+then
+ echo "$MYNAME: pjdfstest binary not found at $pjdfstest_dir/pjdfstest"
+ echo "Please clone and build https://github.com/pjd/pjdfstest"
+ exit 1
+fi
+
+# Backing directory + mountpoint
+DIR=$(mktemp -d "$TMPDIR/$MYNAME.XXX")
+MNT="$DIR.mnt"
+mkdir "$MNT"
+
+../../gocryptfs -q -init -extpass "echo test" -scryptn=10 "$DIR"
+sudo ../../gocryptfs -q -extpass "echo test" -nosyslog -allow_other "$DIR" "$MNT"
+cd "$MNT"
+
+# Cleanup trap
+trap "cd /tmp ; fuse-unmount -z $MNT" EXIT
+
+echo "Starting pjdfstest"
+sudo prove -r "$pjdfstest_dir/tests" \ No newline at end of file
diff --git a/tests/test_helpers/helpers.go b/tests/test_helpers/helpers.go
index c8cd151..fd2ea9c 100644
--- a/tests/test_helpers/helpers.go
+++ b/tests/test_helpers/helpers.go
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strings"
"syscall"
"testing"
@@ -146,6 +147,7 @@ func InitFS(t *testing.T, extraArgs ...string) string {
prefix := "x."
if t != nil {
prefix = t.Name() + "."
+ prefix = strings.ReplaceAll(prefix, "/", "_")
}
dir, err := os.MkdirTemp(TmpDir, prefix)
if err != nil {