4 Commits
v0.8 ... v0.9

5 changed files with 311 additions and 39 deletions

View File

@@ -278,10 +278,30 @@ Two helper functions in `newServer.go` handle context cancellation during stream
- **Regular files**: streamed into tar archive with content - **Regular files**: streamed into tar archive with content
- **Directories**: included as tar directory entries - **Directories**: included as tar directory entries
- **Symlinks**: stored as symlink entries (target path preserved). Backer never resolves or follows symlinks during directory traversal — it uses `os.Lstat` and `filepath.WalkDir` which do not follow symlinks. This means symlink cycles are impossible to occur. - **Symlinks**: stored as symlink entries (target path preserved). Backer never resolves or follows symlinks during directory traversal — it uses `os.Lstat` and `filepath.WalkDir` which do not follow symlinks. This means symlink cycles are impossible to occur.
- **Broken symlinks**: archived with their target path (even if target doesn't exist)
- **Device files** (char/block): included with correct major/minor - **Device files** (char/block): included with correct major/minor
- **Named pipes**: included (header only, no data) - **Named pipes (FIFOs)**: included (header only, no data)
- **Sockets**: skipped (not archivable) - **Sockets**: skipped (not archivable)
- **Hard links**: deduplicated — if multiple files share the same inode, only the first is stored with content, subsequent hard links are stored as link entries pointing to the original - **Hard links**: deduplicated — if multiple files share the same inode, only the first is stored with content, subsequent hard links are stored as link entries pointing to the original
- **GNU tar format**: archives use GNU tar format (`header.Format = tar.FormatGNU`) for compatibility with GNU tar utility
### Archive Test Coverage
All entity types are tested in `internal/backer/utils_test.go`:
| Entity Type | Test Function |
|------------|---------------|
| Regular file | `TestCreateTarGzStreamBasic` |
| Empty file | `TestCreateTarGzStreamEmptyFile` |
| Directory | `TestCreateTarGzStreamDirectory` |
| Symlink | `TestCreateTarGzStreamSymlink` |
| Broken symlink | `TestCreateTarGzStreamBrokenSymlink` |
| Symlink to directory | `TestCreateTarGzStreamSymlinkToDirectory` |
| Hard link | `TestCreateTarGzStreamHardLink` |
| Character device | `TestCreateTarGzStreamDeviceFile` (falls back to char device) |
| Named pipe (FIFO) | `TestCreateTarGzStreamNamedPipe` |
| Special characters in names | `TestCreateTarGzStreamSpecialCharacters` |
| Nested files | `TestCreateTarGzStreamNestedFiles` |
### Pipe Error Recovery ### Pipe Error Recovery

View File

@@ -128,9 +128,12 @@ Backer handles various file types when creating archives:
- **Regular files**: Included with full content - **Regular files**: Included with full content
- **Directories**: Included as directory entries - **Directories**: Included as directory entries
- **Symlinks**: Stored as symlink entries (target path preserved) - **Symlinks**: Stored as symlink entries (target path preserved)
- **Broken symlinks**: Archived with their target path (even if target doesn't exist)
- **Device files**: Character and block devices included with correct major/minor - **Device files**: Character and block devices included with correct major/minor
- **Named pipes**: Included (header only, no data) - **Named pipes (FIFOs)**: Included (header only, no data)
- **Sockets**: Skipped (not archivable)
- **Hard links**: Deduplicated — multiple hard links to the same file are stored as link entries to avoid duplication - **Hard links**: Deduplicated — multiple hard links to the same file are stored as link entries to avoid duplication
- **GNU tar format**: Compatible with GNU tar utility
## Logging ## Logging

View File

@@ -136,7 +136,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
if inode != 0 { if inode != 0 {
if existingPath, seen := seenInodes[inode]; seen { if existingPath, seen := seenInodes[inode]; seen {
// This is a hard link to already-archived file. // This is a hard link to already-archived file.
linkTarget = existingPath // To avoid "tar: Removing leading `/' from hard link targets" message, remove leading slash.
linkTarget = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(existingPath)), "/")
isHardLink = true isHardLink = true
log.Debugf("Hard link detected: %s -> %s", fpath, existingPath) log.Debugf("Hard link detected: %s -> %s", fpath, existingPath)

View File

@@ -8,11 +8,19 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"syscall"
"testing" "testing"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
) )
// mkfifo creates a named pipe (FIFO).
// Uses syscall.Mkfifo which is available on Unix.
func mkfifo(path string, mode os.FileMode) error {
return syscall.Mkfifo(path, uint32(mode))
}
// TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file. // TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file.
func TestCreateTarGzStreamBasic(t *testing.T) { func TestCreateTarGzStreamBasic(t *testing.T) {
// Create temp file // Create temp file
@@ -66,6 +74,49 @@ func TestCreateTarGzStreamBasic(t *testing.T) {
} }
} }
// TestCreateTarGzStreamEmptyFile tests creation of archive with empty file.
func TestCreateTarGzStreamEmptyFile(t *testing.T) {
tmpDir := t.TempDir()
emptyFile := filepath.Join(tmpDir, "empty.txt")
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
reader := CreateTarGzStream(ctx, []string{emptyFile})
defer reader.Close()
gzReader, err := gzip.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
header, err := tarReader.Next()
if err != nil {
t.Fatalf("Failed to read tar header: %v", err)
}
if header.Size != 0 {
t.Errorf("Expected empty file (size 0), got %d", header.Size)
}
if header.Typeflag != tar.TypeReg {
t.Errorf("Expected regular file type, got %c", header.Typeflag)
}
data, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("Failed to read content: %v", err)
}
if len(data) != 0 {
t.Errorf("Expected empty content, got %d bytes", len(data))
}
}
// TestCreateTarGzStreamMultipleFiles tests with multiple files. // TestCreateTarGzStreamMultipleFiles tests with multiple files.
func TestCreateTarGzStreamMultipleFiles(t *testing.T) { func TestCreateTarGzStreamMultipleFiles(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -182,6 +233,82 @@ func TestCreateTarGzStreamSymlink(t *testing.T) {
} }
} }
// TestCreateTarGzStreamBrokenSymlink tests handling of broken symlinks (target doesn't exist).
func TestCreateTarGzStreamBrokenSymlink(t *testing.T) {
tmpDir := t.TempDir()
brokenLink := filepath.Join(tmpDir, "broken.txt")
if err := os.Symlink("/nonexistent/target", brokenLink); err != nil {
t.Skip("Symlinks not supported, skipping test")
}
ctx := context.Background()
reader := CreateTarGzStream(ctx, []string{brokenLink})
defer reader.Close()
gzReader, err := gzip.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
header, err := tarReader.Next()
if err != nil {
t.Fatalf("Failed to read tar header: %v", err)
}
if header.Typeflag != tar.TypeSymlink {
t.Errorf("Expected symlink type, got %c", header.Typeflag)
}
// Broken symlinks are still stored - just the link target path
if header.Linkname != "/nonexistent/target" {
t.Errorf("Expected linkname '/nonexistent/target', got '%s'", header.Linkname)
}
}
// TestCreateTarGzStreamSymlinkToDirectory tests symlink pointing to a directory.
func TestCreateTarGzStreamSymlinkToDirectory(t *testing.T) {
tmpDir := t.TempDir()
targetDir := filepath.Join(tmpDir, "targetdir")
linkToDir := filepath.Join(tmpDir, "linkdir")
if err := os.Mkdir(targetDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(targetDir, linkToDir); err != nil {
t.Skip("Symlinks not supported, skipping test")
}
ctx := context.Background()
reader := CreateTarGzStream(ctx, []string{linkToDir})
defer reader.Close()
gzReader, err := gzip.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
header, err := tarReader.Next()
if err != nil {
t.Fatalf("Failed to read tar header: %v", err)
}
if header.Typeflag != tar.TypeSymlink {
t.Errorf("Expected symlink type, got %c", header.Typeflag)
}
if header.Linkname != targetDir {
t.Errorf("Expected linkname '%s', got '%s'", targetDir, header.Linkname)
}
}
// TestCreateTarGzStreamHardLink tests hard link handling - should deduplicate content. // TestCreateTarGzStreamHardLink tests hard link handling - should deduplicate content.
func TestCreateTarGzStreamHardLink(t *testing.T) { func TestCreateTarGzStreamHardLink(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -231,9 +358,10 @@ func TestCreateTarGzStreamHardLink(t *testing.T) {
if header.Typeflag == tar.TypeLink { if header.Typeflag == tar.TypeLink {
linkCount++ linkCount++
// Verify it's a link to the original // Verify it's a link to the original - code strips leading '/' to avoid tar warnings
if header.Linkname != originalFile && header.Linkname != filepath.ToSlash(originalFile) { expectedLinkname := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(originalFile)), "/")
t.Errorf("Expected linkname '%s', got '%s'", originalFile, header.Linkname) if header.Linkname != expectedLinkname {
t.Errorf("Expected linkname '%s', got '%s'", expectedLinkname, header.Linkname)
} }
} }
} }
@@ -273,8 +401,12 @@ func TestCreateTarGzStreamHardLinkFromTestData(t *testing.T) {
tarReader := tar.NewReader(gzReader) tarReader := tar.NewReader(gzReader)
entries := 0 type entryInfo struct {
linkCount := 0 name string
typeflag byte
linkname string
}
var entries []entryInfo
for { for {
header, err := tarReader.Next() header, err := tarReader.Next()
@@ -285,20 +417,39 @@ func TestCreateTarGzStreamHardLinkFromTestData(t *testing.T) {
t.Fatalf("Failed to read tar entry: %v", err) t.Fatalf("Failed to read tar entry: %v", err)
} }
entries++ entries = append(entries, entryInfo{
t.Logf("Entry %d: name=%s typeflag=%c linkname=%s", entries, header.Name, header.Typeflag, header.Linkname) name: header.Name,
typeflag: header.Typeflag,
linkname: header.Linkname,
})
t.Logf("Entry: name=%s typeflag=%c linkname=%s", header.Name, header.Typeflag, header.Linkname)
}
if header.Typeflag == tar.TypeLink { // Check we have the expected number of entries
linkCount++ if len(entries) != 4 {
t.Errorf("Expected 4 entries (dir + 1 original + 2 hardlinks), got %d", len(entries))
}
// Find the original file (typeflag = TypeReg = '0') and verify hardlinks point to it
var originalName string
var hardlinkCount int
for _, e := range entries {
if e.typeflag == tar.TypeReg && e.linkname == "" {
originalName = filepath.Base(e.name)
}
if e.typeflag == tar.TypeLink {
hardlinkCount++
// Verify link points to the original file's base name
linkBase := filepath.Base(e.linkname)
if linkBase != originalName && linkBase != "original.txt" {
t.Errorf("Hard link %s points to %s, expected %s", filepath.Base(e.name), linkBase, originalName)
}
} }
} }
if entries != 4 { if hardlinkCount != 2 {
t.Errorf("Expected 4 entries (dir + 1 original + 2 hardlinks), got %d", entries) t.Errorf("Expected 2 hard link entries, got %d", hardlinkCount)
}
if linkCount != 2 {
t.Errorf("Expected 2 hard link entries, got %d", linkCount)
} }
} }
@@ -373,6 +524,64 @@ func TestCreateTarGzStreamEmptyList(t *testing.T) {
} }
} }
// TestCreateTarGzStreamSpecialCharacters tests files with special characters in names.
func TestCreateTarGzStreamSpecialCharacters(t *testing.T) {
tmpDir := t.TempDir()
testCases := []struct {
name string
expect string
}{
{"file with spaces.txt", "file with spaces.txt"},
{"file-with-dashes.txt", "file-with-dashes.txt"},
{"file_with_underscores.txt", "file_with_underscores.txt"},
{"file.multiple.dots.txt", "file.multiple.dots.txt"},
}
for _, tc := range testCases {
path := filepath.Join(tmpDir, tc.name)
if err := os.WriteFile(path, []byte(tc.name), 0644); err != nil {
t.Fatal(err)
}
}
ctx := context.Background()
filepaths := make([]string, len(testCases))
for i, tc := range testCases {
filepaths[i] = filepath.Join(tmpDir, tc.name)
}
reader := CreateTarGzStream(ctx, filepaths)
defer reader.Close()
gzReader, err := gzip.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
found := make(map[string]bool)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Failed to read tar entry: %v", err)
}
found[filepath.Base(header.Name)] = true
}
for _, tc := range testCases {
if !found[tc.expect] {
t.Errorf("Expected to find '%s' in archive", tc.expect)
}
}
}
// TestCreateTarGzStreamNestedFiles tests with nested directory structure. // TestCreateTarGzStreamNestedFiles tests with nested directory structure.
func TestCreateTarGzStreamNestedFiles(t *testing.T) { func TestCreateTarGzStreamNestedFiles(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -430,7 +639,43 @@ func TestCreateTarGzStreamNestedFiles(t *testing.T) {
} }
} }
// TestCreateTarGzStreamDeviceFile tests that device files are included in the archive. // TestCreateTarGzStreamNamedPipe tests that named pipes (FIFOs) are included in the archive.
func TestCreateTarGzStreamNamedPipe(t *testing.T) {
tmpDir := t.TempDir()
namedPipe := filepath.Join(tmpDir, "testpipe")
if err := mkfifo(namedPipe, 0644); err != nil {
t.Skip("Named pipes not supported, skipping test")
}
ctx := context.Background()
reader := CreateTarGzStream(ctx, []string{namedPipe})
defer reader.Close()
gzReader, err := gzip.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create gzip reader: %v", err)
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
header, err := tarReader.Next()
if err != nil {
t.Fatalf("Failed to read tar header: %v", err)
}
if header.Typeflag != tar.TypeFifo {
t.Errorf("Expected FIFO type, got %c", header.Typeflag)
}
// Ensure there are no more entries
if _, err := tarReader.Next(); err != io.EOF {
t.Error("Expected EOF after FIFO entry")
}
}
// TestCreateTarGzStreamDeviceFile tests that device files (char and block) are included in the archive.
func TestCreateTarGzStreamDeviceFile(t *testing.T) { func TestCreateTarGzStreamDeviceFile(t *testing.T) {
// Use /dev/null as a test device file (character device). // Use /dev/null as a test device file (character device).
devNull := "/dev/null" devNull := "/dev/null"
@@ -438,8 +683,15 @@ func TestCreateTarGzStreamDeviceFile(t *testing.T) {
if err != nil { if err != nil {
t.Skipf("Skipping device test: %v", err) t.Skipf("Skipping device test: %v", err)
} }
if st.Mode()&os.ModeCharDevice == 0 {
t.Skipf("Skipping: %s is not a character device", devNull) var expectedTypeflag byte
switch {
case st.Mode()&os.ModeCharDevice != 0:
expectedTypeflag = tar.TypeChar
case st.Mode()&os.ModeDevice != 0:
expectedTypeflag = tar.TypeBlock
default:
t.Skipf("Skipping: %s is not a device file", devNull)
} }
ctx := context.Background() ctx := context.Background()
@@ -454,23 +706,13 @@ func TestCreateTarGzStreamDeviceFile(t *testing.T) {
tarReader := tar.NewReader(gzReader) tarReader := tar.NewReader(gzReader)
// Read the device entry
header, err := tarReader.Next() header, err := tarReader.Next()
if err != nil { if err != nil {
t.Fatalf("Failed to read tar header: %v", err) t.Fatalf("Failed to read tar header: %v", err)
} }
if header.Typeflag != tar.TypeChar { if header.Typeflag != expectedTypeflag {
t.Errorf("Expected character device type, got %c", header.Typeflag) t.Errorf("Expected device type %c, got %c", expectedTypeflag, header.Typeflag)
}
// Check that the name matches the file path (without leading slash)
expectedName := devNull
if expectedName[0] == '/' {
expectedName = expectedName[1:]
}
if header.Name != expectedName {
t.Errorf("Expected header name %q, got %q", expectedName, header.Name)
} }
// Ensure there are no more entries // Ensure there are no more entries

View File

@@ -133,12 +133,18 @@ Skips files that can't be read (logs warning, continues backup).
Backup filenames include timestamp and configured prefix: `backup-20060102-150405.tar.gz` (extension depends on compression_algorithm). Backup filenames include timestamp and configured prefix: `backup-20060102-150405.tar.gz` (extension depends on compression_algorithm).
### Special File Types ### Special File Types
- **Device files** (character/block) are included with correct major/minor numbers. - **Regular files**: streamed into tar archive with content
- **Sockets** are skipped (no entry created). - **Directories**: included as tar directory entries
- **Symlinks** are stored as symlinks (target path stored) - uses os.Lstat, no cycles possible. - **Symlinks**: stored as symlink entries (target path preserved) - uses os.Lstat, no cycles possible
- **Named pipes** and other special files are included (tar header created). - **Broken symlinks**: archived with their target path (even if target doesn't exist)
- **Hard links** are deduplicated - first occurrence stored with content, subsequent as link entries. - **Device files** (char/block): included with correct major/minor numbers
- **Regular files** are compressed; other file types have no data section. - **Named pipes (FIFOs)**: included (header only, no data)
- **Sockets**: skipped (not archivable)
- **Hard links**: deduplicated - first occurrence stored with content, subsequent as link entries
- **GNU tar format**: uses `header.Format = tar.FormatGNU` for GNU tar compatibility
### Test Coverage
All entity types are tested: regular files, empty files, directories, symlinks, broken symlinks, symlinks to directories, hard links, device files, named pipes (FIFOs), special characters in filenames.
### Error Recovery ### Error Recovery
- `writeWithContext` checks context before calling fn. - `writeWithContext` checks context before calling fn.