mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 15:23:45 +00:00
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:
parent
2c3338c46b
commit
ef1c2a2280
15
util/sparse/punch.go
Normal file
15
util/sparse/punch.go
Normal 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)
|
||||
}
|
64
util/sparse/punch_darwin.go
Normal file
64
util/sparse/punch_darwin.go
Normal 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
|
||||
}
|
28
util/sparse/punch_generic.go
Normal file
28
util/sparse/punch_generic.go
Normal 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
|
||||
}
|
22
util/sparse/punch_linux.go
Normal file
22
util/sparse/punch_linux.go
Normal 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
89
util/sparse/punch_test.go
Normal 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)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
52
util/sparse/punch_windows.go
Normal file
52
util/sparse/punch_windows.go
Normal 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)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user