From ef1c2a228019587d02e254904593490da812af05 Mon Sep 17 00:00:00 2001 From: James Scott Date: Mon, 24 Feb 2025 15:28:07 -0800 Subject: [PATCH] util/sparse: add a sparse file PunchAt method Adds support for sparse file hole punching in linux, windows, and darwin. Also adds a generic PunchAt method that just writes zeros instead of using the file Punch syscall. Updates tailscale/corp#21363 Signed-off-by: James Scott --- util/sparse/punch.go | 15 ++++++ util/sparse/punch_darwin.go | 64 ++++++++++++++++++++++++++ util/sparse/punch_generic.go | 28 ++++++++++++ util/sparse/punch_linux.go | 22 +++++++++ util/sparse/punch_test.go | 89 ++++++++++++++++++++++++++++++++++++ util/sparse/punch_windows.go | 52 +++++++++++++++++++++ 6 files changed, 270 insertions(+) create mode 100644 util/sparse/punch.go create mode 100644 util/sparse/punch_darwin.go create mode 100644 util/sparse/punch_generic.go create mode 100644 util/sparse/punch_linux.go create mode 100644 util/sparse/punch_test.go create mode 100644 util/sparse/punch_windows.go diff --git a/util/sparse/punch.go b/util/sparse/punch.go new file mode 100644 index 000000000..16d0e9a7a --- /dev/null +++ b/util/sparse/punch.go @@ -0,0 +1,15 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package sparse contains some helpful generic sparse file functions. +package sparse + +import ( + "os" +) + +// PunchAt takes an os.File and offset and size as int64 to punch +// a hole in a sparse file. +func PunchAt(fd *os.File, off, size int64) error { + return punchAt(fd, off, size) +} diff --git a/util/sparse/punch_darwin.go b/util/sparse/punch_darwin.go new file mode 100644 index 000000000..e39a7f9d3 --- /dev/null +++ b/util/sparse/punch_darwin.go @@ -0,0 +1,64 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build darwin + +package sparse + +import ( + "errors" + "io/fs" + "os" + + "golang.org/x/sys/unix" +) + +// punchAt for darwin APFS has a quirk where file punches have to exist on block, +// boundaries. This implementation of PunchAt will handle rounding up to the closest block. +func punchAt(fd *os.File, off, size int64) error { + blockSize, err := getBlockSize(fd) + if err != nil { + return &fs.PathError{Op: "punchAt", Path: fd.Name(), Err: err} + } + off, size, err = alignToBlockSize(off, size, blockSize) + if err != nil { + return &fs.PathError{Op: "punchAt", Path: fd.Name(), Err: err} + } + fstore := &unix.Fstore_t{ + Offset: off, + Length: size, + } + err = unix.FcntlFstore(fd.Fd(), unix.F_PUNCHHOLE, fstore) + if err != nil { + return &fs.PathError{Op: "punchAt", Path: fd.Name(), Err: err} + } + return nil +} + +func getBlockSize(f *os.File) (int64, error) { + var statfs unix.Statfs_t + if err := unix.Fstatfs(int(f.Fd()), &statfs); err != nil { + return 0, err + } + return int64(statfs.Bsize), nil +} + +func alignToBlockSize(off, size, blockSize int64) (int64, int64, error) { + if blockSize <= 0 { + return 0, 0, errors.New("block size too small") + } + + // Align the offset up to the nearest block boundary + alignedOffset := ((off + blockSize - 1) / blockSize) * blockSize + + // Adjust the length to maintain full coverage + adjustment := alignedOffset - off + alignedLength := size - adjustment + if alignedLength < 0 { + alignedLength = 0 + } + // Round length up to the nearest multiple of blockSize + alignedLength = ((alignedLength + blockSize - 1) / blockSize) * blockSize + + return alignedOffset, alignedLength, nil +} diff --git a/util/sparse/punch_generic.go b/util/sparse/punch_generic.go new file mode 100644 index 000000000..c9caefe5f --- /dev/null +++ b/util/sparse/punch_generic.go @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !linux && !darwin && !windows + +package sparse + +import ( + "io" + "os" +) + +// punchAt just calls [writeZeros] in the generic case. +func punchAt(fd *os.File, off, size int64) error { + return writeZeros(fd, off, size) +} + +// The generic unix implementation does not use sparse files. +// It just zeros out the file from the offset for the size bites. +func writeZeros(fd *os.File, off, size int64) error { + _, err := fd.Seek(off, io.SeekStart) + if err != nil { + return err + } + zeros := make([]byte, size) + _, err = fd.Write(zeros) + return err +} diff --git a/util/sparse/punch_linux.go b/util/sparse/punch_linux.go new file mode 100644 index 000000000..0aa9a71ac --- /dev/null +++ b/util/sparse/punch_linux.go @@ -0,0 +1,22 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux + +// Package sparse contains some helpful generic sparse file functions. +package sparse + +import ( + "io/fs" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func punchAt(fd *os.File, off, size int64) error { + if err := syscall.Fallocate(int(fd.Fd()), unix.FALLOC_FL_KEEP_SIZE|unix.FALLOC_FL_PUNCH_HOLE, off, size); err != nil { + return &fs.PathError{Op: "fallocate", Path: fd.Name(), Err: err} + } + return nil +} diff --git a/util/sparse/punch_test.go b/util/sparse/punch_test.go new file mode 100644 index 000000000..bf6d8bcd6 --- /dev/null +++ b/util/sparse/punch_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package sparse + +import ( + "crypto/rand" + "io" + "os" + "testing" + + "tailscale.com/util/must" +) + +func TestFile_PunchAt(t *testing.T) { + type args struct { + fileSize int64 + off int64 + size int64 + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Test PunchAt", + args: args{ + fileSize: 5000, + off: 0, + size: 4096, + }, + wantErr: false, + }, + { + name: "Test PunchAt With FileOffset", + args: args{ + fileSize: 100000, + off: 4096, + size: 4096 * 2, + }, + wantErr: false, + }, + { + name: "Test PunchAt With FileOffset smaller than block size", + args: args{ + fileSize: 100000, + off: 3, + size: 4096 * 2, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := must.Get(os.CreateTemp(t.TempDir(), "punch_at_")) + defer f.Close() + must.Get(io.Copy(f, io.LimitReader(rand.Reader, tt.args.fileSize))) + + if err := PunchAt(f, tt.args.off, tt.args.size); (err != nil) != tt.wantErr { + t.Errorf("PunchAt() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + t.Run("Test PunchAt truncate file twice", func(t *testing.T) { + f := must.Get(os.CreateTemp(t.TempDir(), "punch_at_truncate_")) + defer f.Close() + must.Get(io.Copy(f, io.LimitReader(rand.Reader, 100000))) + offset := int64(4096) + size := int64(4096 * 2) + if err := PunchAt(f, offset, size); err != nil { + t.Errorf("PunchAt() error = %v, wantErr %v", err, false) + } + + // Write random bytes to hole in file. + must.Get(f.Seek(offset, io.SeekStart)) + must.Get(io.Copy(f, io.LimitReader(rand.Reader, size))) + // Change the hole size + offset = 4096 * 2 + size = 4096 + if err := PunchAt(f, offset, size); err != nil { + t.Errorf("PunchAt() error = %v, wantErr %v", err, false) + } + + }) + +} diff --git a/util/sparse/punch_windows.go b/util/sparse/punch_windows.go new file mode 100644 index 000000000..62c24455b --- /dev/null +++ b/util/sparse/punch_windows.go @@ -0,0 +1,52 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build windows + +package sparse + +import ( + "io/fs" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// punchAt for Windows also marks the file as sparse before it punches a hole in it. +func punchAt(fd *os.File, off, size int64) error { + // Windows is unique in that if you call FSCTL_SET_ZERO_DATA on a non sparse file it will just zero out the hole. + // Ensure the file is marked as sparse before punching a hole. + // Docs: https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_set_zero_data#remarks + err := markAsSparseFile(fd) + if err != nil { + return &fs.PathError{Op: "punchAt", Path: fd.Name(), Err: err} + } + fileHandle := syscall.Handle(fd.Fd()) + + // https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ns-winioctl-file_zero_data_information + zeroData := struct { + FileOffset uint64 + ByeondFinalZero uint64 + }{ + FileOffset: uint64(off), + ByeondFinalZero: uint64(off + size), + } + + var bytesReturned uint32 + err = syscall.DeviceIoControl(fileHandle, windows.FSCTL_SET_ZERO_DATA, (*byte)(unsafe.Pointer(&zeroData)), uint32(unsafe.Sizeof(zeroData)), nil, 0, &bytesReturned, nil) + if err != nil { + return &fs.PathError{Op: "punchAt", Path: fd.Name(), Err: err} + } + return err +} + +func markAsSparseFile(file *os.File) error { + fileHandle := syscall.Handle(file.Fd()) + + var bytesReturned uint32 + // FSCTL_SET_SPARSE is the windows syscall to mark a file as sparse. + // Docs: https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ni-winioctl-fsctl_set_sparse + return syscall.DeviceIoControl(fileHandle, windows.FSCTL_SET_SPARSE, nil, 0, nil, 0, &bytesReturned, nil) +}