fusefrontend: rewrite Lseek SEEK_DATA / SEEK_HOLE

In response to the discussion of the xfstests mailing list [1],
I looked at the Lseek implementation, which was naive and
did not handle all cases correctly.

The new implementation aligns the returned values to 4096 bytes
as most callers expect.

A lot of tests are added to verify that we handle all
cases correctly now.

[1]: https://www.spinics.net/lists/fstests/msg16554.html
This commit is contained in:
Jakob Unterwurzacher 2021-05-29 16:05:36 +02:00
parent c1d7e38761
commit 738a9e006a
2 changed files with 147 additions and 32 deletions

View File

@ -4,6 +4,7 @@ package fusefrontend
import (
"context"
"runtime"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
@ -57,12 +58,71 @@ func (f *File) zeroPad(plainSize uint64) syscall.Errno {
}
// Lseek - FUSE call.
//
// Looking at
// fuse_file_llseek @ https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/fs/fuse/file.c?h=v5.12.7#n2634
// this function is only called for SEEK_HOLE & SEEK_DATA.
func (f *File) Lseek(ctx context.Context, off uint64, whence uint32) (uint64, syscall.Errno) {
cipherOff := f.rootNode.contentEnc.PlainSizeToCipherSize(off)
newCipherOff, err := syscall.Seek(f.intFd(), int64(cipherOff), int(whence))
const (
SEEK_DATA = 3 // find next data segment at or above `off`
SEEK_HOLE = 4 // find next hole at or above `off`
// On error, we return -1 as the offset as per man lseek.
MinusOne = ^uint64(0)
)
if whence != SEEK_DATA && whence != SEEK_HOLE {
tlog.Warn.Printf("BUG: Lseek was called with whence=%d. This is not supported!", whence)
return 0, syscall.EINVAL
}
if runtime.GOOS != "linux" {
// MacOS has broken (different?) SEEK_DATA / SEEK_HOLE semantics, see
// https://lists.gnu.org/archive/html/bug-gnulib/2018-09/msg00051.html
tlog.Warn.Printf("buggy on non-linux platforms, disabling SEEK_DATA & SEEK_HOLE")
return MinusOne, syscall.ENOSYS
}
// We will need the file size
var st syscall.Stat_t
err := syscall.Fstat(f.intFd(), &st)
if err != nil {
return 0, fs.ToErrno(err)
}
newOff := f.contentEnc.CipherSizeToPlainSize(uint64(newCipherOff))
return newOff, 0
fileSize := st.Size
// Better safe than sorry. The logic is only tested for 4k blocks.
if st.Blksize != 4096 {
tlog.Warn.Printf("unsupported block size of %d bytes, disabling SEEK_DATA & SEEK_HOLE", st.Blksize)
return MinusOne, syscall.ENOSYS
}
// man lseek: offset beyond end of file -> ENXIO
if f.rootNode.contentEnc.PlainOffToCipherOff(off) >= uint64(fileSize) {
return MinusOne, syscall.ENXIO
}
// Round down to start of block:
cipherOff := f.rootNode.contentEnc.BlockNoToCipherOff(f.rootNode.contentEnc.PlainOffToBlockNo(off))
newCipherOff, err := syscall.Seek(f.intFd(), int64(cipherOff), int(whence))
if err != nil {
return MinusOne, fs.ToErrno(err)
}
// already in data/hole => return original offset
if newCipherOff == int64(cipherOff) {
return off, 0
}
// If there is no further hole, SEEK_HOLE returns the file size
// (SEEK_DATA returns ENXIO in this case).
if whence == SEEK_HOLE {
fi, err := f.fd.Stat()
if err != nil {
return MinusOne, fs.ToErrno(err)
}
if newCipherOff == fi.Size() {
return f.rootNode.contentEnc.CipherSizeToPlainSize(uint64(newCipherOff)), 0
}
}
// syscall.Seek gave us the beginning of the next ext4 data/hole section.
// The next gocryptfs data/hole block starts at the next block boundary,
// so we have to round up:
newBlockNo := f.rootNode.contentEnc.CipherOffToBlockNo(uint64(newCipherOff) + f.rootNode.contentEnc.CipherBS() - 1)
return f.rootNode.contentEnc.BlockNoToPlainOff(newBlockNo), 0
}

View File

@ -2,9 +2,12 @@ package plaintextnames
import (
"fmt"
"math/rand"
"os"
"os/exec"
"syscall"
"testing"
"time"
"github.com/rfjakob/gocryptfs/tests/test_helpers"
@ -26,38 +29,21 @@ func findHolesPretty(t *testing.T, path string) string {
return holes.PrettyPrint(segments)
}
// TestFileHoleCopy creates a sparse times, copies it a few times, and check if
// the copies are the same (including the location of holes and data sections).
//
// The test runs with -plaintextnames because that makes it easier to manipulate
// cipherdir directly.
func TestFileHoleCopy(t *testing.T) {
n := "TestFileHoleCopy"
func doTestFileHoleCopy(t *testing.T, name string, writeOffsets []int64) {
n := "TestFileHoleCopy." + name
pPath := []string{pDir + "/" + n}
cPath := []string{cDir + "/" + n}
f, err := os.Create(pPath[0])
if err != nil {
t.Fatal(err)
}
// | hole | x | hole | x | hole |
buf := []byte("x")
f.WriteAt(buf, 10000)
f.WriteAt(buf, 30000)
f.Truncate(50000)
f.Sync()
f.Close()
os.Remove(pPath[0])
holes.Create(pPath[0])
// You have to update this value manually when you change the sequence
// above
// expected md6
md5 := test_helpers.Md5fn(pPath[0])
if md5 != "4e8d0742bccfbcdbf1d71be688e4e81c" {
t.Fatalf("wrong md5: %s", md5)
}
pSegments := []string{findHolesPretty(t, pPath[0])}
cSegments := []string{findHolesPretty(t, cPath[0])}
// create 5 more copies
for i := 1; i < 5; i++ {
pPath = append(pPath, fmt.Sprintf("%s.%d", pPath[0], i))
cPath = append(cPath, fmt.Sprintf("%s.%d", cPath[0], i))
@ -76,21 +62,90 @@ func TestFileHoleCopy(t *testing.T) {
cSegments = append(cSegments, findHolesPretty(t, cPath[i]))
}
// "cp --sparse=auto" checks of the file has fewer blocks on disk than it
// should have for its size. Only then it will try to create a sparse copy.
var st syscall.Stat_t
err := syscall.Stat(pPath[0], &st)
if err != nil {
t.Fatal(err)
}
// convert 512 byte blocks to 4k blocks
blocks4k := st.Blocks / 8
// For more than a few fragments, ext4 allocates one extra block
blocks4k++
if blocks4k >= (st.Size+4095)/4096 {
t.Logf("file will look non-sparse to cp, skipping segment check")
return
}
// Check that size on disk stays the same across copies
var st0 syscall.Stat_t
if err := syscall.Stat(pPath[0], &st0); err != nil {
t.Fatal(err)
}
for i := range pSegments {
t.Logf("pSegments[%d]: %s", i, pSegments[i])
var st syscall.Stat_t
if err := syscall.Stat(pPath[i], &st); err != nil {
t.Fatal(err)
}
// Sometimes the size on disk decreases by 4k due to less fragmentation
if st.Blocks != st0.Blocks && st.Blocks != st0.Blocks-8 {
t.Errorf("size changed: st0.Blocks=%d st%d.Blocks=%d", st0.Blocks, i, st.Blocks)
}
}
// Check that hole/data segments stays the same across copies
out := ""
same := true
for i := range pSegments {
out += fmt.Sprintf("pSegments[%d]:\n%s\n", i, pSegments[i])
if i < len(pSegments)-1 {
if pSegments[i+1] != pSegments[i] {
t.Errorf("error: this is different than pSegments[%d]!", i+1)
same = false
t.Errorf("error: pSegments[%d] is different than pSegments[%d]!", i, i+1)
}
}
}
t.Log("------------------------------------")
out += "------------------------------------\n"
for i := range cSegments {
t.Logf("cSegments[%d]: %s", i, cSegments[i])
out += fmt.Sprintf("cSegments[%d]:\n%s\n", i, cSegments[i])
if i < len(pSegments)-1 {
if cSegments[i+1] != cSegments[i] {
t.Errorf("error: this is different than cSegments[%d]!", i+1)
same = false
t.Errorf("error: cSegments[%d] is different than cSegments[%d]!", i, i+1)
}
}
}
if !same {
t.Log(out)
}
}
// TestFileHoleCopy creates a sparse times, copies it a few times, and check if
// the copies are the same (including the location of holes and data sections).
//
// The test runs with -plaintextnames because that makes it easier to manipulate
// cipherdir directly.
func TestFileHoleCopy(t *testing.T) {
// | hole | x | hole | x | hole |
// truncate -s 50000 foo && dd if=/dev/zero of=foo bs=1 seek=10000 count=1 conv=notrunc && dd if=/dev/zero of=foo bs=1 seek=30000 count=1 conv=notrunc
name := "c0"
c0 := []int64{10000, 30000}
if !t.Run("c0", func(t *testing.T) { doTestFileHoleCopy(t, name, c0) }) {
t.Log("aborting further subtests")
return
}
rand.Seed(time.Now().UnixNano())
for k := 0; k < 100; k++ {
c1 := make([]int64, 10)
for i := range c1 {
c1[i] = int64(rand.Int31n(60000))
}
name := fmt.Sprintf("k%d", k)
if !t.Run(name, func(t *testing.T) { doTestFileHoleCopy(t, name, c1) }) {
t.Log("aborting further subtests")
return
}
}
}