Add hardlink detection for *nix platforms, they stored as links in tar, so content is effectively deduplites

This commit is contained in:
eleksir
2026-04-04 15:08:54 +03:00
parent fd8612cfc6
commit 6955e14921
4 changed files with 138 additions and 0 deletions

View File

@@ -124,6 +124,16 @@ CompressionLevel int `json:"compression_level"`
| QF1001 | De Morgan's law disabled for auth condition clarity | | QF1001 | De Morgan's law disabled for auth condition clarity |
| revive (error strings) | Same reason as ST1005 | | revive (error strings) | Same reason as ST1005 |
### Design Decisions (Not Issues)
The following are intentional design decisions, not bugs or defects:
- **No rate limiting**: Backups are assumed to be infrequent enough that rate limiting is unnecessary.
- **Basic Auth only**: HTTP Basic Auth is sufficient for a backup server behind a firewall.
- **Global config**: The `C Config` global variable is intentional — config is loaded once at startup.
- **No incremental backup**: Each backup is a full snapshot, not incremental.
- **Credentials in config file**: Config file should have restricted permissions (documented).
## Project Structure ## Project Structure
``` ```
@@ -238,6 +248,7 @@ Two helper functions in `newServer.go` handle context cancellation during stream
- **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**: 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
### Pipe Error Recovery ### Pipe Error Recovery

View File

@@ -111,6 +111,17 @@ good enough cypher.
And one more thing user and password are mandatory, we should have at least some minimal barriers, right? And one more thing user and password are mandatory, we should have at least some minimal barriers, right?
## Supported File Types
Backer handles various file types when creating archives:
- **Regular files**: Included with full content
- **Directories**: Included as directory entries
- **Symlinks**: Stored as symlink entries (target path preserved)
- **Device files**: Character and block devices included with correct major/minor
- **Named pipes**: Included (header only, no data)
- **Hard links**: Deduplicated — multiple hard links to the same file are stored as link entries to avoid duplication
## Logging ## Logging
Backer logs events via Go's `slog`. Log levels: error, warn, info, debug. Backer logs events via Go's `slog`. Log levels: error, warn, info, debug.

View File

@@ -73,7 +73,14 @@ func createArchiveStream(ctx context.Context, filepaths []string, writerFactory
} }
// writeFilesToTar iterates over filepaths and writes each file to the tar archive. // writeFilesToTar iterates over filepaths and writes each file to the tar archive.
// It tracks inodes to handle hard links - if a file has already been archived,
// subsequent hard links to the same inode are stored as link entries.
// This is the Unix implementation (syscall not available on Windows).
func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, closePipeWithError func(error)) { func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, closePipeWithError func(error)) {
// Track inodes to handle hard links: inode -> first path added.
// Uses simple file path tracking on Windows, inode tracking on Unix.
seenInodes := make(map[uint64]string)
for _, fpath := range filepaths { for _, fpath := range filepaths {
var ( var (
err error err error
@@ -116,6 +123,21 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
continue continue
} }
// Check for hard link - if inode was already seen, store as link instead of content.
if st.Mode().IsRegular() {
inode := getInode(st)
if inode != 0 {
if existingPath, seen := seenInodes[inode]; seen {
// This is a hard link to already-archived file.
linkTarget = existingPath
log.Debugf("Hard link detected: %s -> %s", fpath, existingPath)
} else {
// First time seeing this inode - track it.
seenInodes[inode] = fpath
}
}
}
header, err := tar.FileInfoHeader(st, linkTarget) header, err := tar.FileInfoHeader(st, linkTarget)
if err != nil { if err != nil {
@@ -124,6 +146,11 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
continue continue
} }
// If linkTarget is set, ensure the header is marked as a link.
if linkTarget != "" {
header.Typeflag = tar.TypeLink
}
header.Format = tar.FormatGNU header.Format = tar.FormatGNU
header.Name = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(fpath)), "/") header.Name = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(fpath)), "/")
@@ -161,6 +188,27 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
} }
} }
// getInode extracts the inode number from os.FileInfo.
// Returns 0 if unable to determine (e.g., on Windows or with certain file systems).
func getInode(fi os.FileInfo) uint64 {
if fi.Sys() == nil {
return 0
}
// Use type assertion to get inode - works on Unix, returns 0 on Windows.
type inodeGetter interface {
Dev() uint64
Ino() uint64
}
if ig, ok := fi.Sys().(inodeGetter); ok {
// Combine device and inode for uniqueness across file systems.
return (ig.Dev() << 32) | ig.Ino()
}
return 0
}
// isPipeClosedError checks if the error is due to a closed pipe. // isPipeClosedError checks if the error is due to a closed pipe.
func isPipeClosedError(err error) bool { func isPipeClosedError(err error) bool {
if err == nil { if err == nil {

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
@@ -179,6 +180,73 @@ func TestCreateTarGzStreamSymlink(t *testing.T) {
} }
} }
// TestCreateTarGzStreamHardLink tests hard link handling - should deduplicate content.
func TestCreateTarGzStreamHardLink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Hard link detection not supported on Windows (inodes unavailable)")
}
tmpDir := t.TempDir()
// Create original file
originalFile := filepath.Join(tmpDir, "original.txt")
if err := os.WriteFile(originalFile, []byte("shared content"), 0644); err != nil {
t.Fatal(err)
}
// Create hard link
hardLinkFile := filepath.Join(tmpDir, "hardlink.txt")
if err := os.Link(originalFile, hardLinkFile); err != nil {
t.Skip("Hard links not supported, skipping test")
}
ctx := context.Background()
reader := CreateTarGzStream(ctx, []string{originalFile, hardLinkFile})
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)
entries := 0
linkCount := 0
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Failed to read tar entry: %v", err)
}
entries++
t.Logf("Entry %d: name=%s typeflag=%c linkname=%s", entries, header.Name, header.Typeflag, header.Linkname)
if header.Typeflag == tar.TypeLink {
linkCount++
// Verify it's a link to the original
if header.Linkname != originalFile && header.Linkname != filepath.ToSlash(originalFile) {
t.Errorf("Expected linkname '%s', got '%s'", originalFile, header.Linkname)
}
}
}
// Should have 2 entries: one file content, one as hard link
if entries != 2 {
t.Errorf("Expected 2 entries, got %d", entries)
}
// One should be a hard link (TypeLink)
if linkCount != 1 {
t.Errorf("Expected 1 hard link entry, got %d", linkCount)
}
}
// TestCreateTarGzStreamCancellation tests context cancellation. // TestCreateTarGzStreamCancellation tests context cancellation.
func TestCreateTarGzStreamCancellation(t *testing.T) { func TestCreateTarGzStreamCancellation(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()