diff --git a/AGENTS.md b/AGENTS.md index 3646f00..d835c73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,16 @@ CompressionLevel int `json:"compression_level"` | QF1001 | De Morgan's law disabled for auth condition clarity | | 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 ``` @@ -238,6 +248,7 @@ Two helper functions in `newServer.go` handle context cancellation during stream - **Device files** (char/block): included with correct major/minor - **Named pipes**: included (header only, no data) - **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 diff --git a/README.md b/README.md index d4fe4c8..8a0d5db 100644 --- a/README.md +++ b/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? +## 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 Backer logs events via Go's `slog`. Log levels: error, warn, info, debug. diff --git a/internal/backer/archive.go b/internal/backer/archive.go index 964896d..573fccd 100644 --- a/internal/backer/archive.go +++ b/internal/backer/archive.go @@ -73,7 +73,14 @@ func createArchiveStream(ctx context.Context, filepaths []string, writerFactory } // 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)) { + // 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 { var ( err error @@ -116,6 +123,21 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl 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) if err != nil { @@ -124,6 +146,11 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl continue } + // If linkTarget is set, ensure the header is marked as a link. + if linkTarget != "" { + header.Typeflag = tar.TypeLink + } + header.Format = tar.FormatGNU 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. func isPipeClosedError(err error) bool { if err == nil { diff --git a/internal/backer/utils_test.go b/internal/backer/utils_test.go index a9c85dc..82a730b 100644 --- a/internal/backer/utils_test.go +++ b/internal/backer/utils_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "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. func TestCreateTarGzStreamCancellation(t *testing.T) { tmpDir := t.TempDir()