Add hardlink detection for *nix platforms, they stored as links in tar, so content is effectively deduplites
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user