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 <jim@tailscale.com>
This commit is contained in:
James Scott 2025-02-24 15:28:07 -08:00
parent 2c3338c46b
commit ef1c2a2280
No known key found for this signature in database
6 changed files with 270 additions and 0 deletions

15
util/sparse/punch.go Normal file
View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

89
util/sparse/punch_test.go Normal file
View File

@ -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)
}
})
}

View File

@ -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)
}