9 Commits
v0.7 ... v0.8

Author SHA1 Message Date
eleksir
3a6a8e9644 Do not include test_data/{tmp,test1} in git commits 2026-04-18 10:45:57 +03:00
eleksir
5682bad5bb In Makefile also clean up test_data/{tmp,test1} 2026-04-18 10:44:26 +03:00
eleksir
a0fd7881c0 Update llms.txt and AGENTS.md 2026-04-18 10:42:05 +03:00
eleksir
7437af5742 Update tests, avoid relying on pre-existed test data set 2026-04-18 10:39:48 +03:00
eleksir
98e7d0f73e Fix symlinks treted as hardlinks in resulting tar archive 2026-04-18 09:25:13 +03:00
eleksir
d2e0fd2af0 There was a bug where all files treated as hardlinks in resulting tar archive, now it was hopefully fixed 2026-04-18 09:17:16 +03:00
eleksir
51897b20bd Update AGENTS.md 2026-04-18 02:18:02 +03:00
eleksir
8701938c75 Fix some debug messages logged on higher log levels. 2026-04-18 02:17:39 +03:00
eleksir
7df8bc1446 Update comment in newServer.go 2026-04-18 02:16:18 +03:00
18 changed files with 576 additions and 86 deletions

4
.gitignore vendored
View File

@@ -30,4 +30,6 @@ Thumbs.db
*.test *.test
*.out *.out
/test_data/tmp # Test data (generated by tests)
test_data/tmp
test_data/test1

View File

@@ -27,6 +27,36 @@ Backer is a Go-based HTTP/HTTPS backup server that creates compressed archives o
- **Don't use `context` package unless absolutely necessary.** Try to find another solution first. - **Don't use `context` package unless absolutely necessary.** Try to find another solution first.
- **Create functional and unit tests if possible/necessary** to verify changes that software code works at is should. - **Create functional and unit tests if possible/necessary** to verify changes that software code works at is should.
### Test Rules
- **Test data must be self-contained and created at test setup.** All test data should be created in `TestMain` in `internal/backer/main_test.go` before any tests run, not rely on pre-existing files in the repository. Use `test_data/test1/` as the test data directory.
- **Use `test_data/tmp/` for temporary files.** When tests need temporary storage, use this directory (not system `/tmp`) to keep test data isolated and predictable.
- **Always check errors with meaningful diagnostics.** Never discard errors with `_ =`. Use patterns like:
```go
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
```
- **Tests must not depend on external state.** Test data should be created fresh for each test run and cleaned up afterward. Use `TestMain` with setup/cleanup functions.
- **Use `t.Fatal` or `t.Fatalf` for setup errors.** When test setup fails, fail fast with clear diagnostic messages showing what failed and why.
- **Reset global state between tests.** For tests that modify global variables (like `C` config), save the original value and restore it in a `defer`:
```go
original := C
defer func() { C = original }()
```
- **Skip tests gracefully when features are unavailable.** For platform-specific features (hardlinks, symlinks), use `t.Skip()` when the feature is not supported:
```go
if err := os.Link(orig, link); err != nil {
t.Skip("Hard links not supported, skipping test")
}
```
## Build & Test Commands ## Build & Test Commands
```bash ```bash
@@ -276,13 +306,16 @@ Checks `X-Forwarded-For` header first (for proxied requests), then falls back to
### TLS Error Handling ### TLS Error Handling
The server uses a `ServerWrapper` type to intercept and classify errors from `Serve()` and `ServeTLS()`. TLS-specific errors are logged at debug level to reduce log clutter: The server uses a `ServerWrapper` type to intercept and classify errors from `Serve()` and `ServeTLS()`. Two mechanisms:
- Uses `errors.As` to detect TLS error types: `tls.AlertError`, `tls.CertificateVerificationError`, `tls.ECHRejectionError`, `tls.RecordHeaderError` 1. **Server errors** (`Serve()` / `ServeTLS()`): Uses `errors.As` to detect TLS error types (`tls.AlertError`, `tls.CertificateVerificationError`, `tls.ECHRejectionError`, `tls.RecordHeaderError`) and checks for "tls:" prefix. TLS-specific errors logged at debug level, others at warn level.
- Also checks for "tls:" prefix in error messages
- Additionally uses `http.Server.ErrorLog` with a custom logger that writes at debug level for net/http's internal TLS-related messages
This ensures client-side TLS errors (handshake failures, certificate issues) don't clutter production logs. 2. **net/http internal errors** (`http.Server.ErrorLog`): Uses `DebugLogger()` which routes messages through `slogWriter.Write()`:
- TLS-related errors (keywords: "tls", "ssl", "handshake", "certificate") → logged at debug level
- Other errors → logged at warn level
- Both are filtered by configured `loglevel`. Set `loglevel: debug` to see all messages.
This ensures client-side TLS errors (handshake failures, certificate issues) don't clutter production logs by default.
### Compression Level Mapping ### Compression Level Mapping
@@ -356,10 +389,14 @@ Tests live alongside source files as `*_test.go` (package name, not `_test` suff
### Test Data ### Test Data
- `test_data/test_config.json` — valid test config (user: JohnnyGoode, password: SharpShooter) All test data is created by `TestMain` in `internal/backer/main_test.go` before tests run and cleaned up afterward. This ensures tests are independent of pre-existing files.
- `test_data/test1/foo/` — test directory with files, subdirs, empty dirs
- `test_data/test1/foo/` — test directory with files, subdirs, empty_dir
- `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension) - `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension)
- `test_data/example.crt` / `example.key` — TLS test certificates - `test_data/test1/hardlinks/` — test directory with hard-linked files (original.txt, hardlink1.txt, hardlink2.txt)
- `test_data/test1/symlinks/` — test directory with symbolic links
- `test_data/test1/empty_dir/` — empty directory for testing
- `test_data/tmp/` — reserved for test temporary files (use instead of system `/tmp`)
## Service Deployment ## Service Deployment

View File

@@ -23,13 +23,15 @@ build:
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
rm -rf test_data/tmp
rm -rf test_data/test1
upgrade: upgrade:
go get -u ./... go get -u ./...
go mod tidy go mod tidy
go mod vendor go mod vendor
test: test: clean
go test ./... go test ./...
help: help:

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"backer/internal/log" "backer/internal/log"
) )
@@ -85,6 +86,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
var ( var (
err error err error
linkTarget string linkTarget string
isSymlink bool
isHardLink bool
) )
select { select {
@@ -106,6 +109,7 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
continue continue
} }
// Handle symlinks - read target path and mark as symbolic link.
if st.Mode()&os.ModeSymlink != 0 { if st.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(fpath) linkTarget, err = os.Readlink(fpath)
@@ -114,6 +118,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
continue continue
} }
isSymlink = true
} }
// Skip sockets explicitly (they cannot be backed up). // Skip sockets explicitly (they cannot be backed up).
@@ -124,12 +130,15 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
} }
// Check for hard link - if inode was already seen, store as link instead of content. // Check for hard link - if inode was already seen, store as link instead of content.
if st.Mode().IsRegular() { // Only check for regular files (not symlinks).
if st.Mode().IsRegular() && !isSymlink {
inode := getInode(st) inode := getInode(st)
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 linkTarget = existingPath
isHardLink = true
log.Debugf("Hard link detected: %s -> %s", fpath, existingPath) log.Debugf("Hard link detected: %s -> %s", fpath, existingPath)
} else { } else {
// First time seeing this inode - track it. // First time seeing this inode - track it.
@@ -146,9 +155,13 @@ 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. // Set correct type flag for symbolic links or hard links.
if linkTarget != "" { if isSymlink {
header.Typeflag = tar.TypeSymlink
header.Linkname = linkTarget
} else if isHardLink {
header.Typeflag = tar.TypeLink header.Typeflag = tar.TypeLink
header.Linkname = linkTarget
} }
header.Format = tar.FormatGNU header.Format = tar.FormatGNU
@@ -164,8 +177,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
return return
} }
// Only stream file contents for regular files. // Only stream file contents for regular files that are not hard links.
if mode.IsRegular() { if mode.IsRegular() && linkTarget == "" {
f, err := os.Open(fpath) f, err := os.Open(fpath)
if err != nil { if err != nil {
log.Warnf("Skipping %s: open failed: %v", fpath, err) log.Warnf("Skipping %s: open failed: %v", fpath, err)
@@ -195,15 +208,12 @@ func getInode(fi os.FileInfo) uint64 {
return 0 return 0
} }
// Use type assertion to get inode - works on Unix, returns 0 on Windows. // Use reflection to get Dev and Ino fields from the syscall.Stat_t struct.
type inodeGetter interface { st := fi.Sys()
Dev() uint64 // Try type assertion for the struct with fields.
Ino() uint64 switch v := st.(type) {
} case *syscall.Stat_t:
return (v.Dev << 32) | v.Ino
if ig, ok := fi.Sys().(inodeGetter); ok {
// Combine device and inode for uniqueness across file systems.
return (ig.Dev() << 32) | ig.Ino()
} }
return 0 return 0

View File

@@ -13,8 +13,12 @@ func TestLoadConfig(t *testing.T) {
C = Config{} C = Config{}
testDataPath := filepath.Join(t.TempDir(), "test_data") testDataPath := filepath.Join(t.TempDir(), "test_data")
os.MkdirAll(filepath.Join(testDataPath, "test1", "foo"), 0755) if err := os.MkdirAll(filepath.Join(testDataPath, "test1", "foo"), 0755); err != nil {
os.MkdirAll(filepath.Join(testDataPath, "test1", "bar"), 0755) t.Fatalf("Failed to create test directory foo: %v", err)
}
if err := os.MkdirAll(filepath.Join(testDataPath, "test1", "bar"), 0755); err != nil {
t.Fatalf("Failed to create test directory bar: %v", err)
}
// Create test config file // Create test config file
testConfigPath := filepath.Join(testDataPath, "test_config.json") testConfigPath := filepath.Join(testDataPath, "test_config.json")

View File

@@ -0,0 +1,103 @@
package backer
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
)
const testDataPath = "../../test_data"
func TestMain(m *testing.M) {
if err := setupTestData(); err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Failed to setup test data: %v\n", err)
os.Exit(1)
}
code := m.Run()
if err := cleanupTestData(); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: Failed to cleanup test data: %v\n", err)
}
os.Exit(code)
}
func setupTestData() error {
testDir := filepath.Join(testDataPath, "test1")
dirs := []string{
filepath.Join(testDir, "foo", "mydir"),
filepath.Join(testDir, "bar", "goodbye.txt"),
filepath.Join(testDir, "hardlinks"),
filepath.Join(testDir, "symlinks"),
filepath.Join(testDir, "foo", "empty_dir"),
filepath.Join(testDataPath, "tmp"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", dir, err)
}
}
files := map[string]string{
filepath.Join(testDir, "foo", "hello_breakfast.txt"): "hello breakfast content",
filepath.Join(testDir, "foo", "some_text.txt"): "some text content",
filepath.Join(testDir, "foo", "mydir", "myfile.txt"): "my file content",
filepath.Join(testDir, "bar", "hello.txt"): "hello content",
filepath.Join(testDir, "bar", "goodbye.txt", "text.txt"): "text content",
filepath.Join(testDir, "hardlinks", "original.txt"): "original content",
filepath.Join(testDir, "symlinks", "target.txt"): "target content",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", path, err)
}
}
orig := filepath.Join(testDir, "hardlinks", "original.txt")
link1 := filepath.Join(testDir, "hardlinks", "hardlink1.txt")
if err := os.Link(orig, link1); err != nil {
return fmt.Errorf("Failed to create hardlink %s -> %s: %w", link1, orig, err)
}
link2 := filepath.Join(testDir, "hardlinks", "hardlink2.txt")
if err := os.Link(orig, link2); err != nil {
return fmt.Errorf("Failed to create hardlink %s -> %s: %w", link2, orig, err)
}
target := filepath.Join(testDir, "symlinks", "target.txt")
link := filepath.Join(testDir, "symlinks", "link.txt")
if err := os.Symlink(target, link); err != nil {
return fmt.Errorf("Failed to create symlink %s -> %s: %w", link, target, err)
}
return nil
}
func cleanupTestData() error {
testDir := filepath.Join(testDataPath, "test1")
if err := os.RemoveAll(testDir); err != nil {
return fmt.Errorf("Failed to remove test directory %s: %w", testDir, err)
}
tmpDir := filepath.Join(testDataPath, "tmp")
if err := os.RemoveAll(tmpDir); err != nil {
return fmt.Errorf("Failed to remove tmp directory %s: %w", tmpDir, err)
}
return nil
}
func GetTestDataPath() string {
return testDataPath
}
func GetTestFilesFromDirectories(ctx context.Context, directories []string) ([]string, error) {
return GetFilesFromDirectories(ctx, directories)
}

View File

@@ -399,7 +399,7 @@ type (
*http.Server *http.Server
} }
// responseWriterWrapper wraps http.ResponseWriter to capture status code. // responseWriterWrapper wraps [http.ResponseWriter] to capture status code.
responseWriterWrapper struct { responseWriterWrapper struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int

View File

@@ -125,46 +125,43 @@ func TestGetClientIP(t *testing.T) {
// TestGetCompressionAlgorithm tests the getCompressionAlgorithm function. // TestGetCompressionAlgorithm tests the getCompressionAlgorithm function.
func TestGetCompressionAlgorithm(t *testing.T) { func TestGetCompressionAlgorithm(t *testing.T) {
// Save original config
original := C
defer func() { C = original }()
tests := []struct { tests := []struct {
name string name string
ext string ext string
expected string expected string
defaultCompression string
}{ }{
{ {name: "tar.bz2", ext: "tar.bz2", expected: "bzip2"},
name: "tar.bz2", {name: "tar.zstd", ext: "tar.zstd", expected: "zstd"},
ext: "tar.bz2", {name: "tar.zst", ext: "tar.zst", expected: "zstd"},
expected: "bzip2", {name: "tar.lz4", ext: "tar.lz4", expected: "lz4"},
}, {name: "tar.xz", ext: "tar.xz", expected: "xz"},
{ {name: "tar.gz", ext: "tar.gz", expected: "pgzip"},
name: "tar.zst", {name: "bz2", ext: "bz2", expected: "bzip2"},
ext: "tar.zst", {name: "zst", ext: "zst", expected: "zstd"},
expected: "zstd", {name: "lz4", ext: "lz4", expected: "lz4"},
}, {name: "xz", ext: "xz", expected: "xz"},
{ {name: "gz", ext: "gz", expected: "pgzip"},
name: "tar.lz4", {name: "zstd", ext: "zstd", expected: "zstd"},
ext: "tar.lz4", {name: "full path /archive.tar.gz", ext: "/archive.tar.gz", expected: "pgzip"},
expected: "lz4", {name: "full path /archive.tar.xz", ext: "/archive.tar.xz", expected: "xz"},
}, {name: "full path /archive", ext: "/archive", expected: "gzip"},
{ {name: "empty uses default", ext: "", expected: "gzip"},
name: "tar.xz", {name: "unknown uses default", ext: "unknown", expected: "gzip"},
ext: "tar.xz", {name: "unknown with default zstd", ext: "unknown", expected: "zstd", defaultCompression: "zstd"},
expected: "xz",
},
{
name: "tar.gz",
ext: "tar.gz",
expected: "pgzip",
},
{
name: "default with gzip config",
ext: "",
expected: "gzip",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
C = Config{DefaultCompression: "gzip"} if tt.defaultCompression != "" {
C = Config{DefaultCompression: tt.defaultCompression}
} else {
C = Config{DefaultCompression: "gzip"}
}
result := getCompressionAlgorithm(tt.ext) result := getCompressionAlgorithm(tt.ext)
if result != tt.expected { if result != tt.expected {
t.Errorf("getCompressionAlgorithm(%q) = %q, expected %q", tt.ext, result, tt.expected) t.Errorf("getCompressionAlgorithm(%q) = %q, expected %q", tt.ext, result, tt.expected)
@@ -191,6 +188,46 @@ func TestNewServerSuccess(t *testing.T) {
} }
} }
// TestWithServerHeader tests that the Server header is added to responses.
func TestWithServerHeader(t *testing.T) {
handler := withServerHeader(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := &responseWriterWrapper{
ResponseWriter: &testResponseWriter{},
statusCode: 0,
}
req, _ := http.NewRequest("GET", "/test", nil)
handler.ServeHTTP(rec, req)
if rec.Header().Get("Server") != "backer" {
t.Error("Expected Server header to be set to 'backer'")
}
}
// testResponseWriter implements http.ResponseWriter for testing.
type testResponseWriter struct {
header http.Header
status int
}
func (tw *testResponseWriter) Header() http.Header {
if tw.header == nil {
tw.header = make(http.Header)
}
return tw.header
}
func (tw *testResponseWriter) Write(data []byte) (int, error) {
return len(data), nil
}
func (tw *testResponseWriter) WriteHeader(status int) {
tw.status = status
}
// TestNewServerInvalidConfig tests server creation with invalid config. // TestNewServerInvalidConfig tests server creation with invalid config.
func TestNewServerInvalidConfig(t *testing.T) { func TestNewServerInvalidConfig(t *testing.T) {
C = Config{} // Reset global config. C = Config{} // Reset global config.
@@ -618,4 +655,30 @@ func TestMethodNotAllowedHandler(t *testing.T) {
} }
} }
// TestTimingSafeMatch tests the timingSafeMatch function for constant-time comparison.
func TestTimingSafeMatch(t *testing.T) {
tests := []struct {
name string
a string
b string
expected bool
}{
{"empty strings", "", "", true},
{"identical strings", "test", "test", true},
{"different strings", "test", "Test", false},
{"different length", "test", "testing", false},
{"first empty", "", "test", false},
{"second empty", "test", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := timingSafeMatch(tt.a, tt.b)
if result != tt.expected {
t.Errorf("timingSafeMatch(%q, %q) = %v, expected %v", tt.a, tt.b, result, tt.expected)
}
})
}
}
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */ /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,17 @@
package backer
import (
"testing"
)
func TestErrDirectoryScanTimeout(t *testing.T) {
err := ErrDirectoryScanTimeout{ScannedFiles: 42}
if err.Error() != "Directory scan timed out" {
t.Errorf("Expected error message 'Directory scan timed out', got %q", err.Error())
}
if err.ScannedFiles != 42 {
t.Errorf("Expected ScannedFiles=42, got %d", err.ScannedFiles)
}
}

View File

@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"testing" "testing"
"github.com/klauspost/compress/zstd"
) )
// TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file. // TestCreateTarGzStreamBasic tests basic tar.gz creation with a single file.
@@ -247,6 +249,59 @@ func TestCreateTarGzStreamHardLink(t *testing.T) {
} }
} }
// TestCreateTarGzStreamHardLinkFromTestData tests hard link handling with test data.
func TestCreateTarGzStreamHardLinkFromTestData(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Hard link detection not supported on Windows (inodes unavailable)")
}
testDataPath := filepath.Join("../../test_data", "test1", "hardlinks")
filepaths, err := GetFilesFromDirectories(context.Background(), []string{testDataPath})
if err != nil {
t.Fatalf("Failed to get files from directory: %v", err)
}
ctx := context.Background()
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)
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++
}
}
if entries != 4 {
t.Errorf("Expected 4 entries (dir + 1 original + 2 hardlinks), got %d", entries)
}
if linkCount != 2 {
t.Errorf("Expected 2 hard link entries, 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()
@@ -564,4 +619,115 @@ func TestCreateTarXzStream(t *testing.T) {
} }
} }
// TestCreateTarPgzipStream tests pgzip (parallel gzip) compression.
func TestCreateTarPgzipStream(t *testing.T) {
C = Config{CompressionLevel: 6}
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
content := []byte("test content for pgzip")
if err := os.WriteFile(testFile, content, 0644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
reader := CreateTarPgzipStream(ctx, []string{testFile})
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.Name != testFile && filepath.Base(header.Name) != "test.txt" {
t.Errorf("Expected file name 'test.txt', got '%s'", header.Name)
}
data, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("Failed to read file content: %v", err)
}
if string(data) != string(content) {
t.Errorf("Expected content '%s', got '%s'", content, data)
}
}
// TestCreateTarZstdStream tests zstd compression.
func TestCreateTarZstdStream(t *testing.T) {
C = Config{CompressionLevel: 6}
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
content := []byte("test content for zstd")
if err := os.WriteFile(testFile, content, 0644); err != nil {
t.Fatal(err)
}
ctx := context.Background()
reader := CreateTarZstdStream(ctx, []string{testFile})
defer reader.Close()
// zstd.Reader is in github.com/klauspost/compress/zstd
zstdReader, err := zstd.NewReader(reader)
if err != nil {
t.Fatalf("Failed to create zstd reader: %v", err)
}
defer zstdReader.Close()
tarReader := tar.NewReader(zstdReader)
header, err := tarReader.Next()
if err != nil {
t.Fatalf("Failed to read tar header: %v", err)
}
if header.Name != testFile && filepath.Base(header.Name) != "test.txt" {
t.Errorf("Expected file name 'test.txt', got '%s'", header.Name)
}
data, err := io.ReadAll(tarReader)
if err != nil {
t.Fatalf("Failed to read file content: %v", err)
}
if string(data) != string(content) {
t.Errorf("Expected content '%s', got '%s'", content, data)
}
}
// TestZstdLevel tests zstd level mapping.
func TestZstdLevel(t *testing.T) {
tests := []struct {
input int
expected zstd.EncoderLevel
}{
{1, zstd.SpeedFastest},
{2, zstd.SpeedFastest},
{3, zstd.SpeedFastest},
{4, zstd.SpeedDefault},
{5, zstd.SpeedDefault},
{6, zstd.SpeedDefault},
{7, zstd.SpeedBetterCompression},
{8, zstd.SpeedBetterCompression},
{9, zstd.SpeedBetterCompression},
}
for _, tt := range tests {
C = Config{CompressionLevel: tt.input}
result := zstdLevel(tt.input)
if result != tt.expected {
t.Errorf("zstdLevel(%d) = %v, expected %v", tt.input, result, tt.expected)
}
}
}
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */ /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -2,7 +2,6 @@
package log package log
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
@@ -27,11 +26,9 @@ var (
logLevel slog.Level logLevel slog.Level
) )
// slogWriter implements [io.Writer] to redirect standard log to slog.
type ( type (
slogWriter struct { // slogWriter implements [io.Writer] to redirect standard log to slog.
handler slog.Handler slogWriter struct{}
}
) )
// Init sets up logger stuff. // Init sets up logger stuff.
@@ -154,9 +151,9 @@ func Debugf(format string, a ...any) {
slog.Debug(fmt.Sprintf(format, a...)) slog.Debug(fmt.Sprintf(format, a...))
} }
// DebugLogger returns a standard library logger that writes at the configured log level. // DebugLogger returns a standard library logger that writes at debug/warn level.
// This is useful for assigning to http.Server.ErrorLog to route net/http errors // TLS-related errors are logged at debug level, others at warn level.
// through our logger while respecting the configured log level. // Both are filtered by the configured log level: set loglevel to debug to see all messages.
func DebugLogger() *log.Logger { func DebugLogger() *log.Logger {
mu.RLock() mu.RLock()
defer mu.RUnlock() defer mu.RUnlock()
@@ -167,7 +164,9 @@ func DebugLogger() *log.Logger {
handler := slog.NewTextHandler(Log, opts) handler := slog.NewTextHandler(Log, opts)
return log.New(&slogWriter{handler: handler}, "", 0) _ = handler // satisfy linter (handler configures level for potential future use).
return log.New(&slogWriter{}, "", 0)
} }
// Write implements [io.Writer]. It differentiates TLS/SSL errors from other errors. // Write implements [io.Writer]. It differentiates TLS/SSL errors from other errors.
@@ -183,15 +182,12 @@ func (w *slogWriter) Write(p []byte) (int, error) {
strings.Contains(msg, "handshake") || strings.Contains(msg, "handshake") ||
strings.Contains(msg, "certificate") strings.Contains(msg, "certificate")
level := slog.LevelWarn
if isTLSError { if isTLSError {
level = slog.LevelDebug Debug(msg)
} else {
Warn(msg)
} }
record := slog.NewRecord(time.Now(), level, msg, 0)
_ = w.handler.Handle(context.TODO(), record)
return len(p), nil return len(p), nil
} }

View File

@@ -363,7 +363,7 @@ func TestDebugLogger(t *testing.T) {
logFilePath := tmpFile.Name() logFilePath := tmpFile.Name()
tmpFile.Close() tmpFile.Close()
if err := Init("info", logFilePath); err != nil { if err := Init("debug", logFilePath); err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer Close() defer Close()
@@ -385,4 +385,85 @@ func TestDebugLogger(t *testing.T) {
} }
} }
// TestDebugLoggerTLS tests that TLS errors are logged at debug level.
func TestDebugLoggerTLS(t *testing.T) {
tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log")
if err != nil {
t.Fatal(err)
}
logFilePath := tmpFile.Name()
tmpFile.Close()
if err := Init("debug", logFilePath); err != nil {
t.Fatal(err)
}
defer Close()
logger := DebugLogger()
logger.Print("tls handshake timeout")
content, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "tls handshake timeout") {
t.Error("Expected TLS message in log")
}
}
// TestDebugLoggerNonTLS tests that non-TLS errors are logged at warn level.
func TestDebugLoggerNonTLS(t *testing.T) {
tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log")
if err != nil {
t.Fatal(err)
}
logFilePath := tmpFile.Name()
tmpFile.Close()
if err := Init("warn", logFilePath); err != nil {
t.Fatal(err)
}
defer Close()
logger := DebugLogger()
logger.Print("http: connection reset by peer")
content, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "http: connection reset by peer") {
t.Error("Expected non-TLS message in log")
}
}
// TestDebugLoggerLevelFiltering tests that log level filtering works.
func TestDebugLoggerLevelFiltering(t *testing.T) {
tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log")
if err != nil {
t.Fatal(err)
}
logFilePath := tmpFile.Name()
tmpFile.Close()
if err := Init("info", logFilePath); err != nil {
t.Fatal(err)
}
defer Close()
logger := DebugLogger()
logger.Print("tls handshake timeout") // TLS error at debug level - should be filtered
content, err := os.ReadFile(logFilePath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), "tls handshake timeout") {
t.Error("Debug-level TLS message should be filtered at info log level")
}
}
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */ /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -242,25 +242,38 @@ if C.Port < 1 || C.Port > 65535 {
## Testing ## Testing
```bash ```bash
go test ./... # Run tests go test ./... # Run tests (runs make clean first)
go test -v -run TestName ./... # Run specific test go test -v -run TestName ./... # Run specific test
golangci-lint run ./... # Lint golangci-lint run ./... # Lint
make test # Clean + test (deterministic)
``` ```
Coverage: ~69% on internal/backer (~100% on internal/log) Coverage: ~64% on internal/backer (~100% on internal/log)
### Test Data
All test data is created by `TestMain` in `internal/backer/main_test.go` before tests run and cleaned up afterward. This ensures tests are independent of pre-existing files.
- `test_data/test1/foo/` — test directory with files, subdirs, empty_dir
- `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension)
- `test_data/test1/hardlinks/` — test directory with hard-linked files (original.txt, hardlink1.txt, hardlink2.txt)
- `test_data/test1/symlinks/` — test directory with symbolic links
- `test_data/test1/empty_dir/` — empty directory for testing
- `test_data/tmp/` — reserved for test temporary files (use instead of system `/tmp`)
### Test Conventions ### Test Conventions
- Reset global config: `C = Config{}` - Reset global config: `C = Config{}`
- Reset exclude patterns: `excludePatterns = nil` - Reset exclude patterns: `excludePatterns = nil`
- Use `t.TempDir()` for temporary files - Use `t.TempDir()` for temporary files
- Helper function `writeTempConfig()` for config tests - Helper function `writeTempConfig()` for config tests
- Test data created by TestMain setup, cleaned after tests
- Always check errors with meaningful diagnostics (use `t.Fatalf` for setup errors)
## Build ## Build
```bash ```bash
make # Build binary (version from git or "dev") make # Clean + build binary (version from git or "dev")
make build VERSION=0.1.0 # Build with specific version make build VERSION=0.1.0 # Build with specific version
make clean # Remove binary make clean # Remove binary, test_data/tmp, test_data/test1
make test # Run tests make test # Clean + test (deterministic)
make upgrade # Update dependencies make upgrade # Update dependencies
make help # Show targets make help # Show targets
``` ```

View File

@@ -1 +0,0 @@
Fucked up beyond recognotion.

View File

@@ -1,2 +0,0 @@
My dear Tweety,
I'd like to greet you as my breakfast.

View File

@@ -1 +0,0 @@
This is MY file!