Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bee23b9c8 | ||
|
|
55e60a9beb | ||
|
|
a5d2c01cca | ||
|
|
3a16bd2e08 | ||
|
|
3a6a8e9644 | ||
|
|
5682bad5bb | ||
|
|
a0fd7881c0 | ||
|
|
7437af5742 | ||
|
|
98e7d0f73e | ||
|
|
d2e0fd2af0 | ||
|
|
51897b20bd | ||
|
|
8701938c75 | ||
|
|
7df8bc1446 | ||
|
|
b87d520b61 | ||
|
|
cc83c61561 | ||
|
|
4fc69bcf3a | ||
|
|
e4b6a84c2f | ||
|
|
0581d7430c | ||
|
|
b35851567c | ||
|
|
ebd09af6b1 | ||
|
|
0511d47765 | ||
|
|
93b671775f | ||
|
|
c18eb8fb07 | ||
|
|
0cb4d355ab | ||
|
|
6cea15f553 | ||
|
|
6e56e74df4 | ||
|
|
3c22b2e1a0 | ||
|
|
65a57a6af6 | ||
|
|
e063352252 | ||
|
|
f20f490c15 | ||
|
|
ae321a216d | ||
|
|
729b147b90 | ||
|
|
7b001d710a | ||
|
|
2201fc72f1 | ||
|
|
5e7dd4b960 | ||
|
|
c1444f8c1d | ||
|
|
6955e14921 | ||
|
|
fd8612cfc6 | ||
|
|
e449fb78c9 | ||
|
|
958ef67f29 | ||
|
|
14b7a17b00 | ||
|
|
c3553de856 | ||
|
|
06ee9e081f |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
# Binary
|
# Binary
|
||||||
/backer
|
/backer
|
||||||
/backer.exe
|
/backer.exe
|
||||||
|
/cmd/backer/backer
|
||||||
|
/cmd/backer/backer.exe
|
||||||
|
|
||||||
# Local config (may contain secrets)
|
# Local config (may contain secrets)
|
||||||
/config.json
|
/config.json
|
||||||
@@ -27,3 +29,7 @@ Thumbs.db
|
|||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
# Test data (generated by tests)
|
||||||
|
test_data/tmp
|
||||||
|
test_data/test1
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ linters:
|
|||||||
- paramTypeCombine
|
- paramTypeCombine
|
||||||
- whyNoLint
|
- whyNoLint
|
||||||
- hugeParam
|
- hugeParam
|
||||||
|
- unnamedResult
|
||||||
godoclint:
|
godoclint:
|
||||||
default: all
|
default: all
|
||||||
ignore-unexported: false
|
ignore-unexported: false
|
||||||
|
|||||||
142
AGENTS.md
142
AGENTS.md
@@ -20,13 +20,43 @@ Backer is a Go-based HTTP/HTTPS backup server that creates compressed archives o
|
|||||||
- **Use tags and labels.** In comments, add tags or labels to files, classes, or functions to categorize them based on functionality, domain concepts, or design patterns.
|
- **Use tags and labels.** In comments, add tags or labels to files, classes, or functions to categorize them based on functionality, domain concepts, or design patterns.
|
||||||
- **Avoid obvious comments that do not add value.**
|
- **Avoid obvious comments that do not add value.**
|
||||||
- **Remove dead code.** A half-finished refactoring can actually harm the contributions.
|
- **Remove dead code.** A half-finished refactoring can actually harm the contributions.
|
||||||
- **Update AGENTS.md and TODO.md automatically.** Keep AGENTS.md dense, detailed and concise.
|
- **Update AGENTS.md automatically.** Keep AGENTS.md dense, detailed and concise.
|
||||||
|
|
||||||
## Workflow Rules
|
## Workflow Rules
|
||||||
|
|
||||||
- **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
|
||||||
@@ -124,6 +154,18 @@ 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.
|
||||||
|
- **Backup scheduling**: Client-side decision — server serves backups on-request only.
|
||||||
|
- **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).
|
||||||
|
- **Thread-safe logging**: The global `Log` variable uses mutex protection to enable parallel test execution; this is not needed for single-server production use but allows tests to run in parallel without race conditions.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -195,6 +237,7 @@ Config is JSON (HJSON with comments supported). Default path depends on OS:
|
|||||||
| loglevel | `info` | Log verbosity: error/warn/info/debug |
|
| loglevel | `info` | Log verbosity: error/warn/info/debug |
|
||||||
| directories | — | Directories to backup (required, validated for existence) |
|
| directories | — | Directories to backup (required, validated for existence) |
|
||||||
| backup_timeout | `60` | Stream timeout in minutes (1-1440) |
|
| backup_timeout | `60` | Stream timeout in minutes (1-1440) |
|
||||||
|
| dir_scan_timeout | `60` | Directory scan timeout in minutes (1-1440) |
|
||||||
| compression_level | `9` | Gzip level (1-9) |
|
| compression_level | `9` | Gzip level (1-9) |
|
||||||
| exclude_patterns | `[]` | Regex patterns to exclude from backup |
|
| exclude_patterns | `[]` | Regex patterns to exclude from backup |
|
||||||
| filename_prefix | `backup` | Prefix for backup filename in Content-Disposition header |
|
| filename_prefix | `backup` | Prefix for backup filename in Content-Disposition header |
|
||||||
@@ -228,16 +271,37 @@ No temp files on disk. Uses `io.Pipe` with a goroutine to stream archives on-the
|
|||||||
|
|
||||||
Two helper functions in `newServer.go` handle context cancellation during streaming:
|
Two helper functions in `newServer.go` handle context cancellation during streaming:
|
||||||
- `writeWithContext(ctx, fn)` — checks context before calling fn
|
- `writeWithContext(ctx, fn)` — checks context before calling fn
|
||||||
- `copyWithContext(ctx, dst, src)` — buffered copy (32KB) with cancellation checks
|
- `copyWithContext(ctx, dst, src)` — buffered copy (32KB) with cancellation checks, computes MD5 hash of copied data
|
||||||
|
|
||||||
### Special File Type Handling
|
### Special File Type Handling
|
||||||
|
|
||||||
- **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)
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
@@ -245,7 +309,7 @@ Two helper functions in `newServer.go` handle context cancellation during stream
|
|||||||
|
|
||||||
### Auth Handler
|
### Auth Handler
|
||||||
|
|
||||||
HTTP Basic Auth. Both username AND password must match exactly. Returns `401 Unauthorized` with `WWW-Authenticate` header on failure. The condition is intentionally written without De Morgan's law simplification for clarity (linter suppression `//nolint:staticcheck`).
|
HTTP Basic Auth. Both username AND password must match exactly. Returns `401 Unauthorized` with `WWW-Authenticate` header on failure. Uses timing-safe comparison to prevent timing attacks. The condition is intentionally written without De Morgan's law simplification for clarity (linter suppression `//nolint:staticcheck`).
|
||||||
|
|
||||||
### Client IP Detection
|
### Client IP Detection
|
||||||
|
|
||||||
@@ -255,8 +319,66 @@ Checks `X-Forwarded-For` header first (for proxied requests), then falls back to
|
|||||||
|
|
||||||
- `ReadHeaderTimeout`: 5 seconds
|
- `ReadHeaderTimeout`: 5 seconds
|
||||||
- `WriteTimeout`: configurable via `backup_timeout` (converted to `time.Duration`)
|
- `WriteTimeout`: configurable via `backup_timeout` (converted to `time.Duration`)
|
||||||
|
- `IdleTimeout`: 10 seconds (for keep-alive connections; backer disables keep-alives explicitly)
|
||||||
|
- `MaxHeaderBytes`: 1KB (prevents header-based DoS attacks)
|
||||||
|
- `DisableKeepAlives`: true (each connection closed after request, no connection reuse)
|
||||||
- TLS minimum version: TLS 1.3 (when HTTPS enabled)
|
- TLS minimum version: TLS 1.3 (when HTTPS enabled)
|
||||||
|
|
||||||
|
### TLS Error Handling
|
||||||
|
|
||||||
|
The server uses a `ServerWrapper` type to intercept and classify errors from `Serve()` and `ServeTLS()`. Two mechanisms:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The `compression_level` config option (1-9) is mapped differently for each compression algorithm:
|
||||||
|
|
||||||
|
| Algorithm | Level Mapping |
|
||||||
|
|-----------|----------------|
|
||||||
|
| gzip | 1-9 directly maps to gzip compression levels |
|
||||||
|
| pgzip | 1-9 directly maps to pgzip compression levels |
|
||||||
|
| bzip2 | 1-9 directly maps to bzip2 compression levels |
|
||||||
|
| zstd | 1-3: Fastest, 4-6: Default, 7-9: BetterCompression |
|
||||||
|
| lz4 | 1-3: Fastest (0), 4-6: Default (1), 7-9: Best (2) |
|
||||||
|
| xz | Uses default compression (no configurable level) |
|
||||||
|
|
||||||
|
### HTTP Routing & Compression Selection
|
||||||
|
|
||||||
|
The server uses `http.NewServeMux()` for routing. Multiple routes are registered to support different compression formats:
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux.Handle(C.Location, http.HandlerFunc(backupHandler)) // /archive
|
||||||
|
mux.Handle(C.Location+".tar.gz", http.HandlerFunc(backupHandler)) // /archive.tar.gz
|
||||||
|
mux.Handle(C.Location+".tar.xz", http.HandlerFunc(backupHandler)) // /archive.tar.xz
|
||||||
|
mux.Handle(C.Location+".tar.bz2", http.HandlerFunc(backupHandler)) // /archive.tar.bz2
|
||||||
|
// ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
The compression algorithm is determined by extracting the extension from the request path using `path.Ext()`:
|
||||||
|
- For `/archive.tar.xz`, `path.Ext()` returns `.tar.xz` → maps to xz
|
||||||
|
- For `/archive`, `path.Ext()` returns `""` → uses default compression
|
||||||
|
|
||||||
|
The `getCompressionAlgorithm()` function handles both full paths and bare extensions:
|
||||||
|
- Full path: `/archive.tar.xz` → `.tar.xz` → xz
|
||||||
|
- Bare extension: `tar.xz` → `.tar.xz` → xz
|
||||||
|
- Single ext: `xz` → `.xz` → xz
|
||||||
|
|
||||||
|
### Unmatched Route Logging
|
||||||
|
|
||||||
|
The server wraps the `http.ResponseWriter` to capture status codes and log unmatched routes:
|
||||||
|
- **404 Not Found**: Logged as `"Not Found from {clientIP} to {path}"`
|
||||||
|
- **405 Method Not Allowed**: Logged as `"Method Not Allowed from {clientIP} for {method} {path}"`
|
||||||
|
|
||||||
|
This uses a `responseWriterWrapper` that tracks the status code via the `WriteHeader()` method.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `github.com/hjson/hjson-go` v3.3.0 — HJSON config parsing
|
- `github.com/hjson/hjson-go` v3.3.0 — HJSON config parsing
|
||||||
@@ -287,10 +409,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
|
||||||
|
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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:
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -65,6 +65,7 @@ All options described in example config.
|
|||||||
| loglevel | "info" | Verbosity of logs. | Options: error, warn, info, debug. |
|
| loglevel | "info" | Verbosity of logs. | Options: error, warn, info, debug. |
|
||||||
| directories | — | Directories to backup. | Required. Array of paths. |
|
| directories | — | Directories to backup. | Required. Array of paths. |
|
||||||
| backup_timeout | 60 | Timeout in minutes for backup streaming. | Range: 1-1440 minutes. |
|
| backup_timeout | 60 | Timeout in minutes for backup streaming. | Range: 1-1440 minutes. |
|
||||||
|
| dir_scan_timeout | 60 | Timeout in minutes for getting file list from all backupped directories. | Range: 1-1440 minutes. |
|
||||||
| compression_level | 9 | Compression level. | 1 (fastest) to 9 (best compression). |
|
| compression_level | 9 | Compression level. | 1 (fastest) to 9 (best compression). |
|
||||||
| exclude_patterns | [] | Regex patterns to exclude from backup. | E.g., `[".*\\.tmp$", "/node_modules/"]`. |
|
| exclude_patterns | [] | Regex patterns to exclude from backup. | E.g., `[".*\\.tmp$", "/node_modules/"]`. |
|
||||||
| filename_prefix | "backup" | Prefix for backup filename. | E.g., "mybackup" produces `mybackup-20260325-092341.tar.gz`. |
|
| filename_prefix | "backup" | Prefix for backup filename. | E.g., "mybackup" produces `mybackup-20260325-092341.tar.gz`. |
|
||||||
@@ -77,7 +78,7 @@ AI suggests this table as expected performance.
|
|||||||
|
|
||||||
| Algorithm | Speed | Compression | Notes |
|
| Algorithm | Speed | Compression | Notes |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| gzip | Fast | Good | Default. Standard, widely compatible. |
|
| gzip | Fast | Good | Standard, widely compatible. |
|
||||||
| pgzip | Fast | Good | Parallel gzip. Uses multiple CPU cores, slightly larger files. |
|
| pgzip | Fast | Good | Parallel gzip. Uses multiple CPU cores, slightly larger files. |
|
||||||
| bzip2 | Slow | Better | Pure Go. Good compression, slower than gzip. |
|
| bzip2 | Slow | Better | Pure Go. Good compression, slower than gzip. |
|
||||||
| zstd | Fast | Very good | Good balance of speed and compression. |
|
| zstd | Fast | Very good | Good balance of speed and compression. |
|
||||||
@@ -90,12 +91,21 @@ network bandwidth, located in near by country I've got these results
|
|||||||
| Algorithm | Size | Time | CPU Load | RAM RSS+Shm, Mb | Notes |
|
| Algorithm | Size | Time | CPU Load | RAM RSS+Shm, Mb | Notes |
|
||||||
| :--- | ---: | ---: | ---: | ---: | :--- |
|
| :--- | ---: | ---: | ---: | ---: | :--- |
|
||||||
| None | 1461250551 | | 0-2% | 0+0 | original dataset |
|
| None | 1461250551 | | 0-2% | 0+0 | original dataset |
|
||||||
|
| gzip | 823381547 | 16m:22s | 96% | 18+6 | stdlib compression/gzip |
|
||||||
|
| pgzip | 823783924 | 13m:50s | 93-97% | 28+5 | multithreaded (parallel gzip), fully gzip compatible, klauspost/pgzip |
|
||||||
|
| bzip2 | 797053471 | 9m:47s | 92-94% | 56+6 | dsnet/compress/bzip2 |
|
||||||
|
| xz | 515106988 | 16m:03s | 96% | 116+5 | ulikunitz/xz |
|
||||||
|
| zstd | 617330529 | 6m:39s | 40-99% | 64+6 | klauspost/compress/zstd |
|
||||||
| lz4 | 1097745915 | 6m:12s | 4-6% | 30+6 | multithreaded lz4, pierrec/lz4 |
|
| lz4 | 1097745915 | 6m:12s | 4-6% | 30+6 | multithreaded lz4, pierrec/lz4 |
|
||||||
| zstd | 614015773 | 4m:52s | 40-60% | 60+6 | klauspost/compress/zstd |
|
|
||||||
| pgzip | 817163857 | 11m:25s | 96-97% | 37+6 | multithreaded (parallel gzip), fully gzip compatible, klauspost/pgzip |
|
The `compression_level` option (1-9) maps differently for each algorithm:
|
||||||
| gzip | 816734188 | 14m:30s | 96% | 18+6 | stdlib compression/gzip |
|
|
||||||
| bzip2 | 788061284 | 7m:16s | 92-94% | 43+6 | dsnet/compress/bzip2 |
|
| Algorithm | Level Mapping |
|
||||||
| xz | 512004612 | 13m:53s | 96% | 116+6 | ulikunitz/xz |
|
| :--- | :--- |
|
||||||
|
| gzip/pgzip/bzip2 | 1-9 directly maps to compression levels |
|
||||||
|
| lz4 | 1-3 → Fastest (0), 4-6 → Default (1), 7-9 → Best (2) |
|
||||||
|
| zstd | 1-3 → Fastest, 4-6 → Default, 7-9 → BetterCompression |
|
||||||
|
| xz | Uses default compression (no configurable level) |
|
||||||
|
|
||||||
Obvously in case of lz4 and zstd the bottleneck was network. And I have to admit, that network definitely was not
|
Obvously in case of lz4 and zstd the bottleneck was network. And I have to admit, that network definitely was not
|
||||||
100Mbit, but it was pretty hard to measure real bandwidth. Anyway let's pretend that during experiment it was roughly
|
100Mbit, but it was pretty hard to measure real bandwidth. Anyway let's pretend that during experiment it was roughly
|
||||||
@@ -111,6 +121,55 @@ 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)
|
||||||
|
- **Broken symlinks**: Archived with their target path (even if target doesn't exist)
|
||||||
|
- **Device files**: Character and block devices included with correct major/minor
|
||||||
|
- **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
|
||||||
|
- **GNU tar format**: Compatible with GNU tar utility
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Backer logs events via Go's `slog`. Log levels: error, warn, info, debug.
|
||||||
|
### Backup Completion Log
|
||||||
|
|
||||||
|
When a backup completes, the following information is logged:
|
||||||
|
- **client**: Client IP address
|
||||||
|
- **user**: Authenticated username
|
||||||
|
- **files**: Number of files backed up
|
||||||
|
- **bytes**: Size of the created archive in bytes
|
||||||
|
- **md5**: MD5 hash of the created archive (for verification)
|
||||||
|
- **duration**: Time taken to create the archive
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Backup completed: client=127.0.0.1 user=admin files=42 bytes=1234567 md5=abc123def456 duration=2.5s
|
||||||
|
```
|
||||||
|
### TLS Error Handling
|
||||||
|
|
||||||
|
TLS handshake errors and other client-side SSL/TLS errors are logged at **debug** level to reduce log clutter.
|
||||||
|
This includes:
|
||||||
|
- Certificate verification failures
|
||||||
|
- Handshake failures
|
||||||
|
- Protocol errors
|
||||||
|
- TLS-specific connection issues
|
||||||
|
|
||||||
|
### Graceful Shutdown
|
||||||
|
|
||||||
|
When backer receives a termination signal (SIGTERM or SIGINT), it will:
|
||||||
|
1. Log a warning message: "Shutdown signal received, shutting down immediately..."
|
||||||
|
2. Stop accepting new connections and abort any in-progress downloads immediately
|
||||||
|
3. Clean up resources and exit
|
||||||
|
|
||||||
|
This ensures no partial/corrupted backups are left. Clients will need to restart their backup. For a forced immediate stop, use `SIGKILL`.
|
||||||
|
|
||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
This project was developed with the assistance of **opencode** (AI co-programmer powered by MIMO). The AI helped with:
|
This project was developed with the assistance of **opencode** (AI co-programmer powered by MIMO). The AI helped with:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -40,10 +41,14 @@ func main() {
|
|||||||
log.Fatalf("Failed to create server: %v", err)
|
log.Fatalf("Failed to create server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Starting server at %s", server.Addr)
|
// Setup signal handling for immediate shutdown.
|
||||||
|
log.Infof("Installing signal handler")
|
||||||
|
setupSignalHandler(server)
|
||||||
|
|
||||||
|
log.Infof("Starting backer version %s at %s", version, server.Addr)
|
||||||
|
|
||||||
if backer.C.NoHTTPS {
|
if backer.C.NoHTTPS {
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -55,10 +60,12 @@ func main() {
|
|||||||
log.Fatalf("Certificate key file error: %v", err)
|
log.Fatalf("Certificate key file error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := server.ListenAndServeTLS(backer.C.Cert, backer.C.Key); err != nil && err != http.ErrServerClosed {
|
if err := server.ServeTLS(backer.C.Cert, backer.C.Key); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("Server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultConfigPath returns the default config file path based on the OS.
|
// defaultConfigPath returns the default config file path based on the OS.
|
||||||
|
|||||||
29
cmd/backer/sigHandler.go
Normal file
29
cmd/backer/sigHandler.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"backer/internal/backer"
|
||||||
|
"backer/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupSignalHandler sets up signal handling for immediate shutdown.
|
||||||
|
// SIGTERM is commonly sent by systemd, k8s, and other container orchestrators.
|
||||||
|
// SIGINT is sent on Ctrl+C.
|
||||||
|
func setupSignalHandler(server *backer.ServerWrapper) {
|
||||||
|
signalCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-signalCh
|
||||||
|
log.Warnf("Shutdown signal received, shutting down immediately...")
|
||||||
|
// Close the embedded http.Server directly.
|
||||||
|
if closeErr := server.Close(); closeErr != nil {
|
||||||
|
log.Errorf("Error during server shutdown: %v", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
@@ -11,7 +11,6 @@ depend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_pre() {
|
start_pre() {
|
||||||
mkdir -p /var/log/$SVCNAME
|
|
||||||
mkdir -p /run/$SVCNAME
|
mkdir -p /run/$SVCNAME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
/* Regex patterns to exclude from backup. */
|
/* Regex patterns to exclude from backup. */
|
||||||
"exclude_patterns": [],
|
"exclude_patterns": [],
|
||||||
|
|
||||||
|
/* Maximum allowed time, spent for getting file list from backupped directories. */
|
||||||
|
"dir_scan_timeout": 60,
|
||||||
|
|
||||||
/* Directories to backup. */
|
/* Directories to backup. */
|
||||||
"directories": [
|
"directories": [
|
||||||
"/path/to/directory1",
|
"/path/to/directory1",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"backer/internal/log"
|
"backer/internal/log"
|
||||||
)
|
)
|
||||||
@@ -73,11 +74,20 @@ 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
|
||||||
linkTarget string
|
linkTarget string
|
||||||
|
isSymlink bool
|
||||||
|
isHardLink bool
|
||||||
)
|
)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -99,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)
|
||||||
|
|
||||||
@@ -107,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).
|
||||||
@@ -116,6 +129,25 @@ 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.
|
||||||
|
// Only check for regular files (not symlinks).
|
||||||
|
if st.Mode().IsRegular() && !isSymlink {
|
||||||
|
inode := getInode(st)
|
||||||
|
if inode != 0 {
|
||||||
|
if existingPath, seen := seenInodes[inode]; seen {
|
||||||
|
// This is a hard link to already-archived file.
|
||||||
|
// To avoid "tar: Removing leading `/' from hard link targets" message, remove leading slash.
|
||||||
|
linkTarget = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(existingPath)), "/")
|
||||||
|
isHardLink = true
|
||||||
|
|
||||||
|
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 +156,15 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set correct type flag for symbolic links or hard links.
|
||||||
|
if isSymlink {
|
||||||
|
header.Typeflag = tar.TypeSymlink
|
||||||
|
header.Linkname = linkTarget
|
||||||
|
} else if isHardLink {
|
||||||
|
header.Typeflag = tar.TypeLink
|
||||||
|
header.Linkname = linkTarget
|
||||||
|
}
|
||||||
|
|
||||||
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)), "/")
|
||||||
|
|
||||||
@@ -137,8 +178,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)
|
||||||
@@ -146,7 +187,7 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = copyWithContext(ctx, tw, f)
|
_, _, err = copyWithContext(ctx, tw, f)
|
||||||
|
|
||||||
if e := f.Close(); e != nil {
|
if e := f.Close(); e != nil {
|
||||||
log.Warnf("Failed to close file %s: %v", fpath, e)
|
log.Warnf("Failed to close file %s: %v", fpath, e)
|
||||||
@@ -161,6 +202,24 @@ 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 reflection to get Dev and Ino fields from the syscall.Stat_t struct.
|
||||||
|
st := fi.Sys()
|
||||||
|
// Try type assertion for the struct with fields.
|
||||||
|
switch v := st.(type) {
|
||||||
|
case *syscall.Stat_t:
|
||||||
|
return (v.Dev << 32) | v.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 {
|
||||||
|
|||||||
@@ -10,8 +10,22 @@ import (
|
|||||||
// CreateTarBzip2Stream takes a list of files and returns a reader that reads from a bzip2-compressed tar archive.
|
// CreateTarBzip2Stream takes a list of files and returns a reader that reads from a bzip2-compressed tar archive.
|
||||||
func CreateTarBzip2Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
func CreateTarBzip2Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||||
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
||||||
return bzip2.NewWriter(w, nil)
|
level := mapCompressionLevelToBzip2(C.CompressionLevel)
|
||||||
|
|
||||||
|
return bzip2.NewWriter(w, &bzip2.WriterConfig{Level: level})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapCompressionLevelToBzip2 maps 1-9 compression level to bzip2 level.
|
||||||
|
func mapCompressionLevelToBzip2(level int) int {
|
||||||
|
if level < 1 {
|
||||||
|
return bzip2.DefaultCompression
|
||||||
|
}
|
||||||
|
if level > 9 {
|
||||||
|
return bzip2.BestCompression
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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: */
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const (
|
|||||||
// copyBufferSize is the buffer size used for streaming file contents.
|
// copyBufferSize is the buffer size used for streaming file contents.
|
||||||
copyBufferSize = 32 * 1024
|
copyBufferSize = 32 * 1024
|
||||||
|
|
||||||
|
// maxHeaderBytes is the maximum size of request headers (1KB).
|
||||||
|
maxHeaderBytes = 1024
|
||||||
|
|
||||||
|
// idleTimeout is the maximum time to wait for the next request on keep-alive connections.
|
||||||
|
idleTimeout = 10 * time.Second
|
||||||
|
|
||||||
// portMin is the minimum valid port number.
|
// portMin is the minimum valid port number.
|
||||||
portMin = 1
|
portMin = 1
|
||||||
|
|
||||||
@@ -23,6 +29,12 @@ const (
|
|||||||
// backupTimeoutMax is the maximum backup timeout in minutes (24 hours).
|
// backupTimeoutMax is the maximum backup timeout in minutes (24 hours).
|
||||||
backupTimeoutMax = 1440
|
backupTimeoutMax = 1440
|
||||||
|
|
||||||
|
// dirScanTimeoutMin is the minimum directory scan timeout in minutes.
|
||||||
|
dirScanTimeoutMin = 1
|
||||||
|
|
||||||
|
// dirScanTimeoutMax is the maximum directory scan timeout in minutes (24 hours).
|
||||||
|
dirScanTimeoutMax = 1440
|
||||||
|
|
||||||
// compressionLevelMin is the minimum compression level.
|
// compressionLevelMin is the minimum compression level.
|
||||||
compressionLevelMin = 1
|
compressionLevelMin = 1
|
||||||
|
|
||||||
@@ -35,6 +47,9 @@ const (
|
|||||||
// defaultBackupTimeout is the default backup timeout in minutes.
|
// defaultBackupTimeout is the default backup timeout in minutes.
|
||||||
defaultBackupTimeout = 60
|
defaultBackupTimeout = 60
|
||||||
|
|
||||||
|
// defaultDirScanTimeout is the default directory scan timeout in minutes (1 hour).
|
||||||
|
defaultDirScanTimeout = 60
|
||||||
|
|
||||||
// defaultCompressionLevel is the default compression level.
|
// defaultCompressionLevel is the default compression level.
|
||||||
defaultCompressionLevel = 9
|
defaultCompressionLevel = 9
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package backer
|
package backer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"backer/internal/log"
|
backerlog "backer/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isExcluded checks if path matches any of the configured exclude patterns.
|
// isExcluded checks if path matches any of the configured exclude patterns.
|
||||||
@@ -22,19 +25,38 @@ func isExcluded(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFilesFromDirectories makes a file list of given directories.
|
// GetFilesFromDirectories makes a file list of given directories.
|
||||||
func GetFilesFromDirectories(directories []string) ([]string, error) {
|
// Logs errors for directories that don't exist or can't be accessed but continues with valid directories.
|
||||||
|
// Takes context with timeout from config dir_scan_timeout setting.
|
||||||
|
func GetFilesFromDirectories(ctx context.Context, directories []string) ([]string, error) {
|
||||||
var files []string
|
var files []string
|
||||||
|
|
||||||
for _, dir := range directories {
|
for _, dir := range directories {
|
||||||
|
// Check directory exists before walking.
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
backerlog.Errorf("Configured backup directory not found: %s", dir)
|
||||||
|
} else {
|
||||||
|
backerlog.Errorf("Failed to access configured directory %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error { //nolint: revive
|
err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error { //nolint: revive
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Skipping %s: %v", path, err)
|
backerlog.Warnf("Skipping %s: %v", path, err)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if isExcluded(path) {
|
if isExcluded(path) {
|
||||||
log.Debugf("Excluding: %s", path)
|
backerlog.Debugf("Excluding: %s", path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -45,7 +67,13 @@ func GetFilesFromDirectories(directories []string) ([]string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Failed to walk directory %s: %v", dir, err)
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
backerlog.Errorf("Directory scan timed out after scanning %d files", len(files))
|
||||||
|
|
||||||
|
return files, ErrDirectoryScanTimeout{ScannedFiles: len(files)}
|
||||||
|
}
|
||||||
|
|
||||||
|
backerlog.Warnf("Failed to walk directory %s: %v", dir, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package backer
|
package backer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,30 +15,47 @@ func normalizePath(p string) string {
|
|||||||
return strings.ReplaceAll(p, "\\", "/")
|
return strings.ReplaceAll(p, "\\", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizePathSlice normalizes all paths in a slice.
|
||||||
|
func normalizePathSlice(paths []string) []string {
|
||||||
|
result := make([]string, len(paths))
|
||||||
|
for i, p := range paths {
|
||||||
|
result[i] = normalizePath(p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
||||||
func TestGetFilesFromDirectories(t *testing.T) {
|
func TestGetFilesFromDirectories(t *testing.T) {
|
||||||
// Ensure empty_dir exists for the test.
|
testDataPath := "../../test_data"
|
||||||
os.MkdirAll("../../test_data/test1/foo/empty_dir", 0755)
|
|
||||||
|
// Log the paths we're checking for debugging
|
||||||
|
t.Logf("Checking directories: %v", []string{
|
||||||
|
filepath.Join(testDataPath, "test1", "foo"),
|
||||||
|
filepath.Join(testDataPath, "test1", "bar"),
|
||||||
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
input = Config{
|
input = Config{
|
||||||
Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"},
|
Directories: []string{
|
||||||
|
filepath.Join(testDataPath, "test1", "foo"),
|
||||||
|
filepath.Join(testDataPath, "test1", "bar"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
expectedData = []string{
|
expectedData = []string{
|
||||||
"../../test_data/test1/foo",
|
filepath.Join(testDataPath, "test1", "foo"),
|
||||||
"../../test_data/test1/foo/empty_dir",
|
filepath.Join(testDataPath, "test1", "foo", "empty_dir"),
|
||||||
"../../test_data/test1/foo/hello_breakfast.txt",
|
filepath.Join(testDataPath, "test1", "foo", "hello_breakfast.txt"),
|
||||||
"../../test_data/test1/foo/mydir",
|
filepath.Join(testDataPath, "test1", "foo", "mydir"),
|
||||||
"../../test_data/test1/foo/mydir/myfile.txt",
|
filepath.Join(testDataPath, "test1", "foo", "mydir", "myfile.txt"),
|
||||||
"../../test_data/test1/foo/some_text.txt",
|
filepath.Join(testDataPath, "test1", "foo", "some_text.txt"),
|
||||||
"../../test_data/test1/bar",
|
filepath.Join(testDataPath, "test1", "bar"),
|
||||||
"../../test_data/test1/bar/goodbye.txt",
|
filepath.Join(testDataPath, "test1", "bar", "goodbye.txt"),
|
||||||
"../../test_data/test1/bar/goodbye.txt/text.txt",
|
filepath.Join(testDataPath, "test1", "bar", "goodbye.txt", "text.txt"),
|
||||||
"../../test_data/test1/bar/hello.txt",
|
filepath.Join(testDataPath, "test1", "bar", "hello.txt"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
output, err := GetFilesFromDirectories(input.Directories)
|
output, err := GetFilesFromDirectories(context.Background(), input.Directories)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error, got %v", err)
|
t.Errorf("Expected no error, got %v", err)
|
||||||
@@ -51,7 +70,8 @@ func TestGetFilesFromDirectories(t *testing.T) {
|
|||||||
|
|
||||||
for _, filepath := range output {
|
for _, filepath := range output {
|
||||||
normalized := normalizePath(filepath)
|
normalized := normalizePath(filepath)
|
||||||
if !slices.Contains(expectedData, normalized) {
|
normalizedExpected := normalizePathSlice(expectedData)
|
||||||
|
if !slices.Contains(normalizedExpected, normalized) {
|
||||||
t.Errorf("expectedData data does not contain string from output: %s", filepath)
|
t.Errorf("expectedData data does not contain string from output: %s", filepath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +79,8 @@ func TestGetFilesFromDirectories(t *testing.T) {
|
|||||||
|
|
||||||
// TestGetFilesFromDirectoriesSingleDir tests with a single directory.
|
// TestGetFilesFromDirectoriesSingleDir tests with a single directory.
|
||||||
func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
||||||
output, err := GetFilesFromDirectories([]string{"../../test_data/test1/foo"})
|
testDataPath := filepath.Join("../../test_data")
|
||||||
|
output, err := GetFilesFromDirectories(context.Background(), []string{filepath.Join(testDataPath, "test1", "foo")})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error, got %v", err)
|
t.Errorf("Expected no error, got %v", err)
|
||||||
@@ -70,7 +91,7 @@ func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should contain the directory itself
|
// Should contain the directory itself
|
||||||
if !slices.Contains(output, "../../test_data/test1/foo") {
|
if !slices.Contains(output, filepath.Join(testDataPath, "test1", "foo")) {
|
||||||
t.Error("Output should contain the root directory")
|
t.Error("Output should contain the root directory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +101,7 @@ func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
|||||||
// Create temp empty dir
|
// Create temp empty dir
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error, got %v", err)
|
t.Errorf("Expected no error, got %v", err)
|
||||||
@@ -93,23 +114,48 @@ func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestGetFilesFromDirectoriesNonExistent tests with non-existent directory.
|
// TestGetFilesFromDirectoriesNonExistent tests with non-existent directory.
|
||||||
|
// Should log error but not fail - returns empty list.
|
||||||
func TestGetFilesFromDirectoriesNonExistent(t *testing.T) {
|
func TestGetFilesFromDirectoriesNonExistent(t *testing.T) {
|
||||||
output, err := GetFilesFromDirectories([]string{"/nonexistent/path/that/does/not/exist"})
|
testDataPath := filepath.Join("../../test_data")
|
||||||
|
nonExistentPath := filepath.Join(testDataPath, "nonexistent", "path", "that", "does", "not", "exist")
|
||||||
|
output, err := GetFilesFromDirectories(context.Background(), []string{nonExistentPath})
|
||||||
|
|
||||||
|
// Should not return error, but logs error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error (logs warning instead), got %v", err)
|
t.Errorf("Expected no error (logs error instead), got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Should return empty list
|
||||||
if len(output) != 0 {
|
if len(output) != 0 {
|
||||||
t.Errorf("Expected empty output for non-existent directory, got %d files", len(output))
|
t.Errorf("Expected empty output for non-existent directory, got %d files", len(output))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetFilesFromDirectoriesPartialFailure tests with mix of valid and invalid directories.
|
||||||
|
func TestGetFilesFromDirectoriesPartialFailure(t *testing.T) {
|
||||||
|
testDataPath := filepath.Join("../../test_data")
|
||||||
|
// Create a temp directory that exists
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir, filepath.Join(testDataPath, "nonexistent", "path", "that", "does", "not", "exist")})
|
||||||
|
|
||||||
|
// Should not return error, but logs error
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error (logs error instead), got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return files from valid directory
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Error("Expected files from valid directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGetFilesFromDirectoriesMultipleDirs tests multiple directories.
|
// TestGetFilesFromDirectoriesMultipleDirs tests multiple directories.
|
||||||
func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
||||||
output, err := GetFilesFromDirectories([]string{
|
testDataPath := filepath.Join("../../test_data")
|
||||||
"../../test_data/test1/foo",
|
output, err := GetFilesFromDirectories(context.Background(), []string{
|
||||||
"../../test_data/test1/bar",
|
filepath.Join(testDataPath, "test1", "foo"),
|
||||||
|
filepath.Join(testDataPath, "test1", "bar"),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,10 +164,10 @@ func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
|||||||
|
|
||||||
// Should contain entries from both directories
|
// Should contain entries from both directories
|
||||||
hasFoo := slices.ContainsFunc(output, func(s string) bool {
|
hasFoo := slices.ContainsFunc(output, func(s string) bool {
|
||||||
return s == "../../test_data/test1/foo" || s == "../../test_data/test1/foo/hello_breakfast.txt"
|
return s == filepath.Join(testDataPath, "test1", "foo") || s == filepath.Join(testDataPath, "test1", "foo", "hello_breakfast.txt")
|
||||||
})
|
})
|
||||||
hasBar := slices.ContainsFunc(output, func(s string) bool {
|
hasBar := slices.ContainsFunc(output, func(s string) bool {
|
||||||
return s == "../../test_data/test1/bar" || s == "../../test_data/test1/bar/hello.txt"
|
return s == filepath.Join(testDataPath, "test1", "bar") || s == filepath.Join(testDataPath, "test1", "bar", "hello.txt")
|
||||||
})
|
})
|
||||||
|
|
||||||
if !hasFoo {
|
if !hasFoo {
|
||||||
@@ -149,7 +195,7 @@ func TestGetFilesFromDirectoriesSymlink(t *testing.T) {
|
|||||||
t.Skip("Symlinks not supported, skipping test")
|
t.Skip("Symlinks not supported, skipping test")
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error, got %v", err)
|
t.Errorf("Expected no error, got %v", err)
|
||||||
@@ -228,7 +274,7 @@ func TestGetFilesFromDirectoriesWithExcludes(t *testing.T) {
|
|||||||
regexp.MustCompile(`\.tmp$`),
|
regexp.MustCompile(`\.tmp$`),
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ func LoadConfig(path string) error {
|
|||||||
return fmt.Errorf("Config option backup_timeout must be between %d and %d minutes, got %d", backupTimeoutMin, backupTimeoutMax, C.BackupTimeout)
|
return fmt.Errorf("Config option backup_timeout must be between %d and %d minutes, got %d", backupTimeoutMin, backupTimeoutMax, C.BackupTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DirScanTimeout: set default, then validate range.
|
||||||
|
if C.DirScanTimeout == 0 {
|
||||||
|
C.DirScanTimeout = defaultDirScanTimeout
|
||||||
|
|
||||||
|
log.Warnf("Config option dir_scan_timeout is not set, fallback to %d minutes", C.DirScanTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if C.DirScanTimeout < dirScanTimeoutMin || C.DirScanTimeout > dirScanTimeoutMax {
|
||||||
|
return fmt.Errorf("Config option dir_scan_timeout must be between %d and %d minutes, got %d", dirScanTimeoutMin, dirScanTimeoutMax, C.DirScanTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
// CompressionLevel: set default, then validate range.
|
// CompressionLevel: set default, then validate range.
|
||||||
if C.CompressionLevel == 0 {
|
if C.CompressionLevel == 0 {
|
||||||
C.CompressionLevel = defaultCompressionLevel
|
C.CompressionLevel = defaultCompressionLevel
|
||||||
@@ -146,7 +157,7 @@ func LoadConfig(path string) error {
|
|||||||
|
|
||||||
switch C.DefaultCompression {
|
switch C.DefaultCompression {
|
||||||
case "gzip", "pgzip", "bzip2", "xz", "lz4", "zstd":
|
case "gzip", "pgzip", "bzip2", "xz", "lz4", "zstd":
|
||||||
log.Infof("Using %s as defailt compression algorithm", C.DefaultCompression)
|
// No-op: compression algorithm validated.
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("Config option default_compression must be gzip, bzip2, zstd, lz4, xz or pgzip, got %s", C.DefaultCompression)
|
return fmt.Errorf("Config option default_compression must be gzip, bzip2, zstd, lz4, xz or pgzip, got %s", C.DefaultCompression)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package backer
|
package backer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -10,18 +12,50 @@ func TestLoadConfig(t *testing.T) {
|
|||||||
// Reset global config
|
// Reset global config
|
||||||
C = Config{}
|
C = Config{}
|
||||||
|
|
||||||
var (
|
testDataPath := filepath.Join(t.TempDir(), "test_data")
|
||||||
testConfigPath = "./../../test_data/test_config.json"
|
if err := os.MkdirAll(filepath.Join(testDataPath, "test1", "foo"), 0755); err != nil {
|
||||||
expectedData = Config{
|
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
|
||||||
|
testConfigPath := filepath.Join(testDataPath, "test_config.json")
|
||||||
|
testStruct := struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Directories []string `json:"directories"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
NoHTTPS bool `json:"nohttps"`
|
||||||
|
}{
|
||||||
|
Address: "127.0.0.1",
|
||||||
|
Port: 8086,
|
||||||
|
Directories: []string{filepath.Join(testDataPath, "test1", "foo"), filepath.Join(testDataPath, "test1", "bar")},
|
||||||
|
Location: "/archive",
|
||||||
|
User: "test",
|
||||||
|
Password: "test",
|
||||||
|
NoHTTPS: true,
|
||||||
|
}
|
||||||
|
testConfigBytes, _ := json.MarshalIndent(testStruct, "", " ")
|
||||||
|
if err := os.WriteFile(testConfigPath, testConfigBytes, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedData := Config{
|
||||||
Address: "127.0.0.1",
|
Address: "127.0.0.1",
|
||||||
Port: 8086,
|
Port: 8086,
|
||||||
Directories: []string{
|
Directories: []string{
|
||||||
"../../test_data/test1/foo",
|
filepath.Join(testDataPath, "test1", "foo"),
|
||||||
"../../test_data/test1/bar",
|
filepath.Join(testDataPath, "test1", "bar"),
|
||||||
},
|
},
|
||||||
Location: "/archive",
|
Location: "/archive",
|
||||||
|
User: "test",
|
||||||
|
Password: "test",
|
||||||
|
NoHTTPS: true,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
err := LoadConfig(testConfigPath)
|
err := LoadConfig(testConfigPath)
|
||||||
|
|
||||||
@@ -57,6 +91,8 @@ func TestLoadConfig(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigMissingUser tests that config fails when user is missing.
|
// TestLoadConfigMissingUser tests that config fails when user is missing.
|
||||||
func TestLoadConfigMissingUser(t *testing.T) {
|
func TestLoadConfigMissingUser(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -72,6 +108,8 @@ func TestLoadConfigMissingUser(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigMissingPassword tests that config fails when password is missing.
|
// TestLoadConfigMissingPassword tests that config fails when password is missing.
|
||||||
func TestLoadConfigMissingPassword(t *testing.T) {
|
func TestLoadConfigMissingPassword(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -87,6 +125,8 @@ func TestLoadConfigMissingPassword(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigMissingDirectories tests that config fails when directories is missing.
|
// TestLoadConfigMissingDirectories tests that config fails when directories is missing.
|
||||||
func TestLoadConfigMissingDirectories(t *testing.T) {
|
func TestLoadConfigMissingDirectories(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -102,6 +142,8 @@ func TestLoadConfigMissingDirectories(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigInvalidPort tests that config fails with invalid port.
|
// TestLoadConfigInvalidPort tests that config fails with invalid port.
|
||||||
func TestLoadConfigInvalidPort(t *testing.T) {
|
func TestLoadConfigInvalidPort(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 99999,
|
"port": 99999,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -118,6 +160,8 @@ func TestLoadConfigInvalidPort(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigNonExistentDirectory tests that config fails with non-existent directory.
|
// TestLoadConfigNonExistentDirectory tests that config fails with non-existent directory.
|
||||||
func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -134,6 +178,8 @@ func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigHTTPSWithoutCert tests that config fails when HTTPS enabled but no cert.
|
// TestLoadConfigHTTPSWithoutCert tests that config fails when HTTPS enabled but no cert.
|
||||||
func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -151,6 +197,8 @@ func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigNoHTTPS tests that config succeeds with nohttps=true and no cert.
|
// TestLoadConfigNoHTTPS tests that config succeeds with nohttps=true and no cert.
|
||||||
func TestLoadConfigNoHTTPS(t *testing.T) {
|
func TestLoadConfigNoHTTPS(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"user": "test",
|
"user": "test",
|
||||||
@@ -172,6 +220,8 @@ func TestLoadConfigNoHTTPS(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigFilenamePrefixCustom tests custom filename prefix.
|
// TestLoadConfigFilenamePrefixCustom tests custom filename prefix.
|
||||||
func TestLoadConfigFilenamePrefixCustom(t *testing.T) {
|
func TestLoadConfigFilenamePrefixCustom(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -193,6 +243,8 @@ func TestLoadConfigFilenamePrefixCustom(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigDefaultValues tests default values are set correctly.
|
// TestLoadConfigDefaultValues tests default values are set correctly.
|
||||||
func TestLoadConfigDefaultValues(t *testing.T) {
|
func TestLoadConfigDefaultValues(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -234,6 +286,8 @@ func TestLoadConfigDefaultValues(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmBzip2 tests bzip2 compression algorithm.
|
// TestLoadConfigCompressionAlgorithmBzip2 tests bzip2 compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmBzip2(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmBzip2(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -255,6 +309,8 @@ func TestLoadConfigCompressionAlgorithmBzip2(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmZstd tests zstd compression algorithm.
|
// TestLoadConfigCompressionAlgorithmZstd tests zstd compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -276,6 +332,8 @@ func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmLz4 tests lz4 compression algorithm.
|
// TestLoadConfigCompressionAlgorithmLz4 tests lz4 compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -297,6 +355,8 @@ func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmXz tests xz compression algorithm.
|
// TestLoadConfigCompressionAlgorithmXz tests xz compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmXz(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmXz(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -318,6 +378,8 @@ func TestLoadConfigCompressionAlgorithmXz(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmPgzip tests pgzip compression algorithm.
|
// TestLoadConfigCompressionAlgorithmPgzip tests pgzip compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmPgzip(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmPgzip(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -339,6 +401,8 @@ func TestLoadConfigCompressionAlgorithmPgzip(t *testing.T) {
|
|||||||
|
|
||||||
// TestLoadConfigCompressionAlgorithmInvalid tests invalid compression algorithm.
|
// TestLoadConfigCompressionAlgorithmInvalid tests invalid compression algorithm.
|
||||||
func TestLoadConfigCompressionAlgorithmInvalid(t *testing.T) {
|
func TestLoadConfigCompressionAlgorithmInvalid(t *testing.T) {
|
||||||
|
// Reset global config
|
||||||
|
C = Config{}
|
||||||
config := `{
|
config := `{
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"password": "test",
|
"password": "test",
|
||||||
@@ -367,7 +431,6 @@ func writeTempConfig(t *testing.T, content string) string {
|
|||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
// Reset global config before loading
|
// Reset global config before loading
|
||||||
C = Config{}
|
C = Config{}
|
||||||
excludePatterns = nil
|
|
||||||
return tmpFile.Name()
|
return tmpFile.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,26 @@ import (
|
|||||||
// CreateTarLz4Stream takes a list of files and returns a reader that reads from an lz4-compressed tar archive.
|
// CreateTarLz4Stream takes a list of files and returns a reader that reads from an lz4-compressed tar archive.
|
||||||
func CreateTarLz4Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
func CreateTarLz4Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||||
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
||||||
return lz4.NewWriter(w), nil
|
lz4Writer := lz4.NewWriter(w)
|
||||||
|
// Map compression_level (1-9) to lz4 compression level.
|
||||||
|
// 1-3: fast, 4-6: balanced, 7-9: better compression.
|
||||||
|
lz4Writer.Header.CompressionLevel = mapCompressionLevelToLz4(C.CompressionLevel) //nolint: staticcheck
|
||||||
|
|
||||||
|
return lz4Writer, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapCompressionLevelToLz4 maps 1-9 compression level to lz4 compression level.
|
||||||
|
// 1-3: fast, 4-6: balanced, 7-9: better compression.
|
||||||
|
func mapCompressionLevelToLz4(level int) int {
|
||||||
|
switch {
|
||||||
|
case level <= 3:
|
||||||
|
return 0 // Fastest.
|
||||||
|
case level <= 6:
|
||||||
|
return 1 // Default.
|
||||||
|
default:
|
||||||
|
return 2 // Best compression.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
103
internal/backer/main_test.go
Normal file
103
internal/backer/main_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ package backer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -14,6 +18,53 @@ import (
|
|||||||
"backer/internal/log"
|
"backer/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isTLSError checks if the given error is TLS-related.
|
||||||
|
// It checks for both TLS-specific error types and error messages containing "tls:".
|
||||||
|
func isTLSError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var alertErr *tls.AlertError
|
||||||
|
if errors.As(err, &alertErr) && alertErr != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var certErr *tls.CertificateVerificationError
|
||||||
|
if errors.As(err, &certErr) && certErr != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var echErr *tls.ECHRejectionError
|
||||||
|
if errors.As(err, &echErr) && echErr != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &recordErr) && recordErr != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.HasPrefix(err.Error(), "tls:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// withStatusLogging wraps a handler to log 404 and 405 responses.
|
||||||
|
func withStatusLogging(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rw := &responseWriterWrapper{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
|
||||||
|
clientIP := getClientIP(r)
|
||||||
|
|
||||||
|
switch rw.statusCode {
|
||||||
|
case http.StatusNotFound:
|
||||||
|
log.Infof("Not Found from %s to %s", clientIP, r.RequestURI)
|
||||||
|
case http.StatusMethodNotAllowed:
|
||||||
|
log.Infof("Method Not Allowed from %s for %s %s", clientIP, r.Method, r.RequestURI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// withServerHeader wraps a handler to add Server header to all responses.
|
// withServerHeader wraps a handler to add Server header to all responses.
|
||||||
func withServerHeader(next http.Handler) http.Handler {
|
func withServerHeader(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,7 +74,8 @@ func withServerHeader(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewServer starts new server instance with given config file.
|
// NewServer starts new server instance with given config file.
|
||||||
func NewServer(configPath string) (*http.Server, error) {
|
// It returns a ServerWrapper that intercepts and classifies server errors.
|
||||||
|
func NewServer(configPath string) (*ServerWrapper, error) {
|
||||||
_ = log.Init("info", "") // Initialize logger with defaults for early logging.
|
_ = log.Init("info", "") // Initialize logger with defaults for early logging.
|
||||||
|
|
||||||
if err := LoadConfig(configPath); err != nil {
|
if err := LoadConfig(configPath); err != nil {
|
||||||
@@ -34,9 +86,18 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
log.Errorf("Failed to initialize logger: %v", err)
|
log.Errorf("Failed to initialize logger: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Infof("Using %s as default compression algorithm", C.DefaultCompression)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc(C.Location, func(w http.ResponseWriter, r *http.Request) {
|
backupHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only GET method is allowed.
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
username, password, ok := r.BasicAuth()
|
username, password, ok := r.BasicAuth()
|
||||||
|
|
||||||
// No auth? Good bye.
|
// No auth? Good bye.
|
||||||
@@ -44,16 +105,18 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
|
||||||
|
log.Infof("Access denied from %s to %s (no username or password given)", r.RemoteAddr, r.RequestURI)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both username AND password must match. Explicitly require that the whole condition
|
// Both username AND password must match. Use timing-safe comparison to prevent timing attacks.
|
||||||
// fails when either is incorrect, per the authentication design.
|
if !timingSafeMatch(username, C.User) || !timingSafeMatch(password, C.Password) {
|
||||||
//nolint:staticcheck // QF1001: De Morgan's law is irrelevant here.
|
|
||||||
if !(username == C.User && password == C.Password) {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
|
||||||
|
log.Infof("Access denied from %s to %s (username and/or password incorrect)", r.RemoteAddr, r.RequestURI)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,25 +125,41 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
|
|
||||||
log.Infof("Backup started: client=%s user=%s", clientIP, username)
|
log.Infof("Backup started: client=%s user=%s", clientIP, username)
|
||||||
|
|
||||||
ctx := r.Context()
|
// Create context with dir_scan_timeout from request context.
|
||||||
files, err := GetFilesFromDirectories(C.Directories)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(C.DirScanTimeout)*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
files, err := GetFilesFromDirectories(ctx, C.Directories)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
w.Header().Set("Server", "backer")
|
||||||
|
http.Error(w, "Directory scan timed out", http.StatusRequestTimeout)
|
||||||
|
|
||||||
|
log.Errorf("Directory scan timed out: %v", err)
|
||||||
|
|
||||||
|
default:
|
||||||
log.Errorf("Failed to get files: %v", err)
|
log.Errorf("Failed to get files: %v", err)
|
||||||
|
|
||||||
w.Header().Set("Server", "backer")
|
w.Header().Set("Server", "backer")
|
||||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
log.Warnf("No files found to backup in configured directories")
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
archive io.ReadCloser
|
archive io.ReadCloser
|
||||||
extension string
|
extension string
|
||||||
algorithm string
|
algorithm string
|
||||||
)
|
)
|
||||||
|
|
||||||
requestedExt := strings.TrimPrefix(path.Ext(r.URL.Path), ".")
|
requestedExt := r.URL.Path
|
||||||
|
|
||||||
algorithm = getCompressionAlgorithm(requestedExt)
|
algorithm = getCompressionAlgorithm(requestedExt)
|
||||||
|
|
||||||
@@ -91,7 +170,11 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
|
|
||||||
case "zstd":
|
case "zstd":
|
||||||
archive = CreateTarZstdStream(ctx, files)
|
archive = CreateTarZstdStream(ctx, files)
|
||||||
|
if strings.HasSuffix(requestedExt, ".zst") {
|
||||||
extension = "tar.zst"
|
extension = "tar.zst"
|
||||||
|
} else {
|
||||||
|
extension = "tar.zstd"
|
||||||
|
}
|
||||||
|
|
||||||
case "lz4":
|
case "lz4":
|
||||||
archive = CreateTarLz4Stream(ctx, files)
|
archive = CreateTarLz4Stream(ctx, files)
|
||||||
@@ -116,44 +199,80 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timestamp := time.Now().Format("20060102-150405")
|
timestamp := backupStart.Format("20060102-150405")
|
||||||
filename := fmt.Sprintf("%s-%s.%s", C.FilenamePrefix, timestamp, extension)
|
filename := fmt.Sprintf("%s-%s.%s", C.FilenamePrefix, timestamp, extension)
|
||||||
|
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
bytesWritten, err := io.Copy(w, archive)
|
bytesWritten, md5Hash, err := copyWithContext(ctx, w, archive)
|
||||||
|
if err != nil {
|
||||||
duration := time.Since(backupStart)
|
duration := time.Since(backupStart)
|
||||||
|
|
||||||
if err != nil {
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Infof("Backup canceled: client=%s user=%s duration=%s", clientIP, username, duration)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPipeClosedError(err) {
|
||||||
|
log.Infof("Client disconnected during backup: client=%s user=%s", clientIP, username)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Errorf("Backup failed: client=%s user=%s duration=%s error=%v", clientIP, username, duration, err)
|
log.Errorf("Backup failed: client=%s user=%s duration=%s error=%v", clientIP, username, duration, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d duration=%s", clientIP, username, len(files), bytesWritten, duration)
|
log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d md5=%s duration=%s", clientIP, username, len(files), bytesWritten, md5Hash, time.Since(backupStart))
|
||||||
})
|
}
|
||||||
|
|
||||||
|
mux.Handle(C.Location, http.HandlerFunc(backupHandler))
|
||||||
|
|
||||||
|
extensions := []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tar.lz4", ".tar.zst", ".tar.zstd"}
|
||||||
|
for _, ext := range extensions {
|
||||||
|
muxPath := C.Location + ext
|
||||||
|
mux.Handle(muxPath, http.HandlerFunc(backupHandler))
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP mode if nohttps is enabled in config.
|
// HTTP mode if nohttps is enabled in config.
|
||||||
// HTTPS mode if nohttps is disabled in config.
|
// HTTPS mode if nohttps is disabled in config.
|
||||||
if C.NoHTTPS {
|
if C.NoHTTPS {
|
||||||
return &http.Server{
|
srv := &ServerWrapper{
|
||||||
|
Server: &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||||
Handler: withServerHeader(mux),
|
Handler: withServerHeader(withStatusLogging(mux)),
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||||
}, nil
|
IdleTimeout: idleTimeout,
|
||||||
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
|
ErrorLog: log.DebugLogger(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv.SetKeepAlivesEnabled(false)
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &http.Server{
|
srv := &ServerWrapper{
|
||||||
|
Server: &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||||
Handler: withServerHeader(mux),
|
Handler: withServerHeader(withStatusLogging(mux)),
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
MinVersion: tls.VersionTLS13,
|
MinVersion: tls.VersionTLS13,
|
||||||
},
|
},
|
||||||
}, nil
|
ErrorLog: log.DebugLogger(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv.SetKeepAlivesEnabled(false)
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeWithContext checks if context is canceled before executing fn.
|
// writeWithContext checks if context is canceled before executing fn.
|
||||||
@@ -169,31 +288,50 @@ func writeWithContext(ctx context.Context, fn func() error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
||||||
// context cancellation. If the context is canceled, the copy stops and returns
|
// context cancellation. Returns bytes written, MD5 hash (hex string), and any error encountered.
|
||||||
// ctx.Err().
|
//
|
||||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error {
|
//nolint:gosec
|
||||||
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, string, error) {
|
||||||
buf := make([]byte, copyBufferSize)
|
buf := make([]byte, copyBufferSize)
|
||||||
|
|
||||||
|
var bytesWritten int64
|
||||||
|
|
||||||
|
hasher := md5.New()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return bytesWritten, "", ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
n, readErr := src.Read(buf)
|
n, readErr := src.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
select {
|
||||||
return werr
|
case <-ctx.Done():
|
||||||
|
return bytesWritten, "", ctx.Err()
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, werr := hasher.Write(buf[:n]); werr != nil {
|
||||||
|
return bytesWritten, "", werr
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||||
|
return bytesWritten, "", werr
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesWritten += int64(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
if readErr == io.EOF {
|
if readErr == io.EOF {
|
||||||
return nil
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
return bytesWritten, hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return readErr
|
return bytesWritten, "", readErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,21 +354,99 @@ func getClientIP(r *http.Request) string {
|
|||||||
// getCompressionAlgorithm determines which compression algorithm to use based on the requested extension.
|
// getCompressionAlgorithm determines which compression algorithm to use based on the requested extension.
|
||||||
// If the extension is recognized, it returns the corresponding algorithm; otherwise, it returns the default.
|
// If the extension is recognized, it returns the corresponding algorithm; otherwise, it returns the default.
|
||||||
// For .tar.gz extension, pgzip is always used for parallel compression.
|
// For .tar.gz extension, pgzip is always used for parallel compression.
|
||||||
func getCompressionAlgorithm(requestedExt string) string {
|
// The input can be either a full path like "/archive.tar.xz" or just the extension like "tar.xz" or "xz".
|
||||||
switch requestedExt {
|
func getCompressionAlgorithm(input string) string {
|
||||||
case "tar.bz2":
|
// Extract extension from full path (e.g., "/archive.tar.xz" -> ".xz").
|
||||||
|
// or use the input directly if it's just an extension (e.g., "tar.xz" -> ".xz").
|
||||||
|
ext := path.Ext(input)
|
||||||
|
if ext == "" {
|
||||||
|
ext = "." + input
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".tar.bz2":
|
||||||
return "bzip2"
|
return "bzip2"
|
||||||
case "tar.zst":
|
case ".tar.zstd":
|
||||||
return "zstd"
|
return "zstd"
|
||||||
case "tar.lz4":
|
case ".tar.zst":
|
||||||
|
return "zstd"
|
||||||
|
case ".tar.lz4":
|
||||||
return "lz4"
|
return "lz4"
|
||||||
case "tar.xz":
|
case ".tar.xz":
|
||||||
return "xz"
|
return "xz"
|
||||||
case "tar.gz":
|
case ".tar.gz":
|
||||||
|
return "pgzip"
|
||||||
|
case ".xz":
|
||||||
|
return "xz"
|
||||||
|
case ".bz2":
|
||||||
|
return "bzip2"
|
||||||
|
case ".zst":
|
||||||
|
return "zstd"
|
||||||
|
case ".zstd":
|
||||||
|
return "zstd"
|
||||||
|
case ".lz4":
|
||||||
|
return "lz4"
|
||||||
|
case ".gz":
|
||||||
return "pgzip"
|
return "pgzip"
|
||||||
default:
|
default:
|
||||||
return C.DefaultCompression
|
return C.DefaultCompression
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
// ServerWrapper wraps [http.Server] to intercept and classify errors.
|
||||||
|
ServerWrapper struct {
|
||||||
|
*http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseWriterWrapper wraps [http.ResponseWriter] to capture status code.
|
||||||
|
responseWriterWrapper struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteHeader captures the status code for logging.
|
||||||
|
func (rw *responseWriterWrapper) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts the server and classifies any errors returned.
|
||||||
|
// TLS-related errors are logged at debug level, other errors at warn level.
|
||||||
|
func (sw *ServerWrapper) Serve() error {
|
||||||
|
err := sw.ListenAndServe()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if isTLSError(err) {
|
||||||
|
log.Debugf("Server error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Warnf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeTLS starts the TLS server and classifies any errors returned.
|
||||||
|
func (sw *ServerWrapper) ServeTLS(certFile, keyFile string) error {
|
||||||
|
err := sw.ListenAndServeTLS(certFile, keyFile)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if isTLSError(err) {
|
||||||
|
log.Debugf("Server TLS error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Warnf("Server TLS error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// timingSafeMatch performs constant-time comparison to prevent timing attacks.
|
||||||
|
// Returns 1 if the strings are equal, 0 otherwise.
|
||||||
|
func timingSafeMatch(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -11,6 +13,163 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestIsTLSError tests the isTLSError function.
|
||||||
|
func TestIsTLSError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil error",
|
||||||
|
err: nil,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regular error",
|
||||||
|
err: errors.New("some random error"),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS prefix error",
|
||||||
|
err: errors.New("tls: handshake failure"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS AlertError",
|
||||||
|
err: tls.AlertError(80),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS CertificateVerificationError",
|
||||||
|
err: &tls.CertificateVerificationError{},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS ECHRejectionError",
|
||||||
|
err: &tls.ECHRejectionError{RetryConfigList: []byte("rejected")},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS RecordHeaderError",
|
||||||
|
err: &tls.RecordHeaderError{},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isTLSError(tt.err)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isTLSError(%v) = %v, expected %v", tt.err, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetClientIP tests the getClientIP function.
|
||||||
|
func TestGetClientIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
xffHeader string
|
||||||
|
remoteAddr string
|
||||||
|
expectedIP string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no X-Forwarded-For",
|
||||||
|
xffHeader: "",
|
||||||
|
remoteAddr: "192.168.1.100:12345",
|
||||||
|
expectedIP: "192.168.1.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single IP in X-Forwarded-For",
|
||||||
|
xffHeader: "10.0.0.1",
|
||||||
|
remoteAddr: "192.168.1.100:12345",
|
||||||
|
expectedIP: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple IPs in X-Forwarded-For",
|
||||||
|
xffHeader: "10.0.0.1, 192.168.1.100, 172.16.0.1",
|
||||||
|
remoteAddr: "192.168.1.100:12345",
|
||||||
|
expectedIP: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Forwarded-For with spaces",
|
||||||
|
xffHeader: " 10.0.0.1 , 192.168.1.100 ",
|
||||||
|
remoteAddr: "192.168.1.100:12345",
|
||||||
|
expectedIP: "10.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid RemoteAddr",
|
||||||
|
xffHeader: "",
|
||||||
|
remoteAddr: "invalid",
|
||||||
|
expectedIP: "invalid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/archive", nil)
|
||||||
|
if tt.xffHeader != "" {
|
||||||
|
req.Header.Set("X-Forwarded-For", tt.xffHeader)
|
||||||
|
}
|
||||||
|
req.RemoteAddr = tt.remoteAddr
|
||||||
|
|
||||||
|
result := getClientIP(req)
|
||||||
|
if result != tt.expectedIP {
|
||||||
|
t.Errorf("getClientIP() = %q, expected %q", result, tt.expectedIP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCompressionAlgorithm tests the getCompressionAlgorithm function.
|
||||||
|
func TestGetCompressionAlgorithm(t *testing.T) {
|
||||||
|
// Save original config
|
||||||
|
original := C
|
||||||
|
defer func() { C = original }()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ext string
|
||||||
|
expected string
|
||||||
|
defaultCompression string
|
||||||
|
}{
|
||||||
|
{name: "tar.bz2", ext: "tar.bz2", expected: "bzip2"},
|
||||||
|
{name: "tar.zstd", ext: "tar.zstd", expected: "zstd"},
|
||||||
|
{name: "tar.zst", ext: "tar.zst", expected: "zstd"},
|
||||||
|
{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: "bz2", ext: "bz2", expected: "bzip2"},
|
||||||
|
{name: "zst", ext: "zst", expected: "zstd"},
|
||||||
|
{name: "lz4", ext: "lz4", expected: "lz4"},
|
||||||
|
{name: "xz", ext: "xz", expected: "xz"},
|
||||||
|
{name: "gz", ext: "gz", expected: "pgzip"},
|
||||||
|
{name: "zstd", ext: "zstd", expected: "zstd"},
|
||||||
|
{name: "full path /archive.tar.gz", ext: "/archive.tar.gz", expected: "pgzip"},
|
||||||
|
{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: "unknown uses default", ext: "unknown", expected: "gzip"},
|
||||||
|
{name: "unknown with default zstd", ext: "unknown", expected: "zstd", defaultCompression: "zstd"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.defaultCompression != "" {
|
||||||
|
C = Config{DefaultCompression: tt.defaultCompression}
|
||||||
|
} else {
|
||||||
|
C = Config{DefaultCompression: "gzip"}
|
||||||
|
}
|
||||||
|
result := getCompressionAlgorithm(tt.ext)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("getCompressionAlgorithm(%q) = %q, expected %q", tt.ext, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewServerSuccess tests successful server creation.
|
// TestNewServerSuccess tests successful server creation.
|
||||||
func TestNewServerSuccess(t *testing.T) {
|
func TestNewServerSuccess(t *testing.T) {
|
||||||
C = Config{} // Reset global config.
|
C = Config{} // Reset global config.
|
||||||
@@ -29,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.
|
||||||
@@ -189,7 +388,7 @@ func TestHandlerArchiveStream(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
files, err := GetFilesFromDirectories(C.Directories)
|
files, err := GetFilesFromDirectories(ctx, C.Directories)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -301,7 +500,7 @@ func TestCopyWithContextSuccess(t *testing.T) {
|
|||||||
src := strings.NewReader("hello world")
|
src := strings.NewReader("hello world")
|
||||||
dst := &strings.Builder{}
|
dst := &strings.Builder{}
|
||||||
|
|
||||||
err := copyWithContext(ctx, dst, src)
|
_, _, err := copyWithContext(ctx, dst, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected no error, got %v", err)
|
t.Errorf("Expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,6 +510,25 @@ func TestCopyWithContextSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCopyWithContextMD5Hash tests that MD5 hash is correctly computed.
|
||||||
|
func TestCopyWithContextMD5Hash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
data := "hello world"
|
||||||
|
src := strings.NewReader(data)
|
||||||
|
dst := &strings.Builder{}
|
||||||
|
|
||||||
|
_, md5Hash, err := copyWithContext(ctx, dst, src)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5 of "hello world" is 5eb63bbbe01eeed093cb22bb8f5acdc3
|
||||||
|
expectedMD5 := "5eb63bbbe01eeed093cb22bb8f5acdc3"
|
||||||
|
if md5Hash != expectedMD5 {
|
||||||
|
t.Errorf("Expected MD5 %s, got %s", expectedMD5, md5Hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCopyWithContextCancellation tests copy with cancelled context.
|
// TestCopyWithContextCancellation tests copy with cancelled context.
|
||||||
func TestCopyWithContextCancellation(t *testing.T) {
|
func TestCopyWithContextCancellation(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -325,17 +543,142 @@ func TestCopyWithContextCancellation(t *testing.T) {
|
|||||||
|
|
||||||
// Start copy in goroutine
|
// Start copy in goroutine
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
bytesCh := make(chan int64, 1)
|
||||||
go func() {
|
go func() {
|
||||||
errCh <- copyWithContext(ctx, dst, src)
|
n, _, err := copyWithContext(ctx, dst, src)
|
||||||
|
bytesCh <- n
|
||||||
|
errCh <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Cancel context immediately
|
// Cancel context immediately
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := <-errCh
|
err := <-errCh
|
||||||
|
<-bytesCh
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
t.Errorf("Expected context.Canceled, got %v", err)
|
t.Errorf("Expected context.Canceled, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStatusLogging404 tests that 404 responses are logged.
|
||||||
|
func TestStatusLogging404(t *testing.T) {
|
||||||
|
C = Config{} // Reset global config.
|
||||||
|
excludePatterns = nil
|
||||||
|
|
||||||
|
// Create a minimal mux that returns 404 for all paths
|
||||||
|
testMux := http.NewServeMux()
|
||||||
|
testMux.HandleFunc("/archive", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrappedMux := withStatusLogging(testMux)
|
||||||
|
|
||||||
|
// Use a ResponseRecorder to capture the response
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/nonexistent", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:12345"
|
||||||
|
|
||||||
|
wrappedMux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatusLogging405 tests that 405 responses are logged.
|
||||||
|
func TestStatusLogging405(t *testing.T) {
|
||||||
|
C = Config{} // Reset global config.
|
||||||
|
excludePatterns = nil
|
||||||
|
|
||||||
|
// Create a minimal mux that returns 405 for wrong method
|
||||||
|
testMux := http.NewServeMux()
|
||||||
|
testMux.HandleFunc("/archive", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
})
|
||||||
|
|
||||||
|
wrappedMux := withStatusLogging(testMux)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/archive", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:12345"
|
||||||
|
|
||||||
|
wrappedMux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected status 405, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMethodNotAllowedHandler tests that non-GET methods return 405.
|
||||||
|
func TestMethodNotAllowedHandler(t *testing.T) {
|
||||||
|
C = Config{
|
||||||
|
Address: "0.0.0.0",
|
||||||
|
Port: 8086,
|
||||||
|
Location: "/archive",
|
||||||
|
User: "testuser",
|
||||||
|
Password: "testpass",
|
||||||
|
Directories: []string{"../../test_data/test1/foo"},
|
||||||
|
}
|
||||||
|
excludePatterns = nil
|
||||||
|
|
||||||
|
// Create test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != C.User || password != C.Password {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
w.Header().Set("Server", "backer")
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Test POST method
|
||||||
|
req, _ := http.NewRequest("POST", server.URL+"/archive", nil)
|
||||||
|
req.SetBasicAuth("testuser", "testpass")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected status 405, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: */
|
||||||
|
|||||||
@@ -15,12 +15,23 @@ type (
|
|||||||
LogLevel string `json:"loglevel"`
|
LogLevel string `json:"loglevel"`
|
||||||
Directories []string `json:"directories"`
|
Directories []string `json:"directories"`
|
||||||
BackupTimeout int `json:"backup_timeout"` // Timeout in minutes for backup streaming.
|
BackupTimeout int `json:"backup_timeout"` // Timeout in minutes for backup streaming.
|
||||||
|
DirScanTimeout int `json:"dir_scan_timeout"` // Timeout in minutes for directory scanning.
|
||||||
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default 9).
|
CompressionLevel int `json:"compression_level"` // Compression level (1-9, default 9).
|
||||||
ExcludePatterns []string `json:"exclude_patterns"` // Regex patterns to exclude from backup.
|
ExcludePatterns []string `json:"exclude_patterns"` // Regex patterns to exclude from backup.
|
||||||
FilenamePrefix string `json:"filename_prefix"` // Prefix for backup filename (default: "backup").
|
FilenamePrefix string `json:"filename_prefix"` // Prefix for backup filename (default: "backup").
|
||||||
CompressionAlgorithm string `json:"compression_algorithm"` // Compression algorithm alias (deprecated: use default_compression).
|
CompressionAlgorithm string `json:"compression_algorithm"` // Compression algorithm alias (deprecated: use default_compression).
|
||||||
DefaultCompression string `json:"default_compression"` // Default compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz.
|
DefaultCompression string `json:"default_compression"` // Default compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDirectoryScanTimeout is returned when directory scan times out.
|
||||||
|
ErrDirectoryScanTimeout struct {
|
||||||
|
ScannedFiles int
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Error returns the error message for ErrDirectoryScanTimeout.
|
||||||
|
func (e ErrDirectoryScanTimeout) Error() string {
|
||||||
|
return "Directory scan timed out"
|
||||||
|
}
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
17
internal/backer/types_test.go
Normal file
17
internal/backer/types_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,20 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"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
|
||||||
@@ -63,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()
|
||||||
@@ -179,6 +233,226 @@ 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.
|
||||||
|
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 - code strips leading '/' to avoid tar warnings
|
||||||
|
expectedLinkname := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(originalFile)), "/")
|
||||||
|
if header.Linkname != expectedLinkname {
|
||||||
|
t.Errorf("Expected linkname '%s', got '%s'", expectedLinkname, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
type entryInfo struct {
|
||||||
|
name string
|
||||||
|
typeflag byte
|
||||||
|
linkname string
|
||||||
|
}
|
||||||
|
var entries []entryInfo
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read tar entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entryInfo{
|
||||||
|
name: header.Name,
|
||||||
|
typeflag: header.Typeflag,
|
||||||
|
linkname: header.Linkname,
|
||||||
|
})
|
||||||
|
t.Logf("Entry: name=%s typeflag=%c linkname=%s", header.Name, header.Typeflag, header.Linkname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check we have the expected number of entries
|
||||||
|
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 hardlinkCount != 2 {
|
||||||
|
t.Errorf("Expected 2 hard link entries, got %d", hardlinkCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCreateTarGzStreamCancellation tests context cancellation.
|
// TestCreateTarGzStreamCancellation tests context cancellation.
|
||||||
func TestCreateTarGzStreamCancellation(t *testing.T) {
|
func TestCreateTarGzStreamCancellation(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -250,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()
|
||||||
@@ -307,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"
|
||||||
@@ -315,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()
|
||||||
@@ -331,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
|
||||||
@@ -356,4 +721,255 @@ func TestCreateTarGzStreamDeviceFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCompressionLevelMappingLz4 tests that lz4 compression levels are correctly mapped.
|
||||||
|
func TestCompressionLevelMappingLz4(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{1, 0},
|
||||||
|
{2, 0},
|
||||||
|
{3, 0},
|
||||||
|
{4, 1},
|
||||||
|
{5, 1},
|
||||||
|
{6, 1},
|
||||||
|
{7, 2},
|
||||||
|
{8, 2},
|
||||||
|
{9, 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
C = Config{CompressionLevel: tt.input}
|
||||||
|
result := mapCompressionLevelToLz4(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("mapCompressionLevelToLz4(%d) = %d, expected %d", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompressionLevelMappingXz tests that xz compression is not configurable via level.
|
||||||
|
// xz uses default compression - this test verifies the stream works.
|
||||||
|
func TestCompressionLevelMappingXz(t *testing.T) {
|
||||||
|
// xz doesn't support configurable compression levels in the same way.
|
||||||
|
// It always uses default compression. This test verifies the stream is created successfully.
|
||||||
|
C = Config{CompressionLevel: 6}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
content := []byte("test content")
|
||||||
|
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := CreateTarXzStream(ctx, []string{testFile})
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read xz archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("Expected non-empty archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompressionLevelMappingBzip2 tests that bzip2 compression levels are correctly mapped.
|
||||||
|
func TestCompressionLevelMappingBzip2(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{1, 1},
|
||||||
|
{5, 5},
|
||||||
|
{9, 9},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
C = Config{CompressionLevel: tt.input}
|
||||||
|
result := mapCompressionLevelToBzip2(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("mapCompressionLevelToBzip2(%d) = %d, expected %d", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTarBzip2Stream tests bzip2 compression with configured level.
|
||||||
|
func TestCreateTarBzip2Stream(t *testing.T) {
|
||||||
|
C = Config{CompressionLevel: 6}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
content := []byte("test content for bzip2")
|
||||||
|
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := CreateTarBzip2Stream(ctx, []string{testFile})
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Verify we can read the archive
|
||||||
|
_, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read bzip2 archive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTarLz4Stream tests lz4 compression with configured level.
|
||||||
|
func TestCreateTarLz4Stream(t *testing.T) {
|
||||||
|
C = Config{CompressionLevel: 6}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
content := []byte("test content for lz4")
|
||||||
|
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := CreateTarLz4Stream(ctx, []string{testFile})
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Verify we can read the archive
|
||||||
|
_, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read lz4 archive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateTarXzStream tests xz compression with configured level.
|
||||||
|
func TestCreateTarXzStream(t *testing.T) {
|
||||||
|
C = Config{CompressionLevel: 6}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
content := []byte("test content for xz")
|
||||||
|
if err := os.WriteFile(testFile, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := CreateTarXzStream(ctx, []string{testFile})
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Verify we can read the archive
|
||||||
|
_, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read xz archive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: */
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
// CreateTarXzStream takes a list of files and returns a reader that reads from an xz-compressed tar archive.
|
// CreateTarXzStream takes a list of files and returns a reader that reads from an xz-compressed tar archive.
|
||||||
func CreateTarXzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
func CreateTarXzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||||
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
return createArchiveStream(ctx, filepaths, func(w io.Writer) (io.WriteCloser, error) {
|
||||||
|
// xz doesn't have a simple level parameter; use default compression.
|
||||||
|
// The compression level affects block size indirectly through the algorithm.
|
||||||
return xz.NewWriter(w)
|
return xz.NewWriter(w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,52 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Log contains *[os.File] pointing to log file descriptor.
|
// Log contains *[os.File] pointing to log file descriptor.
|
||||||
|
// Protected by mu for thread-safe access, primarily to enable parallel tests.
|
||||||
|
// In production, this is set once at startup and never modified thereafter.
|
||||||
Log *os.File
|
Log *os.File
|
||||||
|
|
||||||
|
// mu protects Log during initialization and when DebugLogger is called.
|
||||||
|
// This is necessary to allow parallel test execution where each test
|
||||||
|
// may initialize the logger with a different log file.
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// logLevel stores the configured log level for use by DebugLogger.
|
||||||
|
logLevel slog.Level
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// slogWriter implements [io.Writer] to redirect standard log to slog.
|
||||||
|
slogWriter struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init sets up logger stuff.
|
// Init sets up logger stuff.
|
||||||
// level can be error, warn, info, debug; if something other is supplied, info level is selected.
|
// level can be error, warn, info, debug; if something other is supplied, info level is selected.
|
||||||
|
// Thread-safe: uses mutex to allow parallel test execution.
|
||||||
func Init(level, filename string) error {
|
func Init(level, filename string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
err error
|
err error
|
||||||
loglevel slog.Level
|
loglevel slog.Level
|
||||||
)
|
)
|
||||||
|
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
|
// Close existing log file if re-initializing (e.g., in tests).
|
||||||
|
if Log != nil && Log != os.Stderr {
|
||||||
|
Log.Close()
|
||||||
|
}
|
||||||
|
|
||||||
Log, err = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
Log, err = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,6 +76,8 @@ func Init(level, filename string) error {
|
|||||||
loglevel = slog.LevelInfo
|
loglevel = slog.LevelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logLevel = loglevel
|
||||||
|
|
||||||
opts := &slog.HandlerOptions{
|
opts := &slog.HandlerOptions{
|
||||||
// Use the ReplaceAttr function on the handler options
|
// Use the ReplaceAttr function on the handler options
|
||||||
// to be able to replace any single attribute in the log output.
|
// to be able to replace any single attribute in the log output.
|
||||||
@@ -122,6 +151,46 @@ 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 debug/warn level.
|
||||||
|
// TLS-related errors are logged at debug level, others at warn level.
|
||||||
|
// Both are filtered by the configured log level: set loglevel to debug to see all messages.
|
||||||
|
func DebugLogger() *log.Logger {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
|
Level: logLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(Log, opts)
|
||||||
|
|
||||||
|
_ = 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.
|
||||||
|
// TLS-related errors are logged at debug level, while other errors (handler issues,
|
||||||
|
// unexpected behavior) are logged at warn level.
|
||||||
|
func (w *slogWriter) Write(p []byte) (int, error) {
|
||||||
|
msg := string(p)
|
||||||
|
|
||||||
|
isTLSError := strings.Contains(msg, "tls") ||
|
||||||
|
strings.Contains(msg, "ssl") ||
|
||||||
|
strings.Contains(msg, "TLS") ||
|
||||||
|
strings.Contains(msg, "SSL") ||
|
||||||
|
strings.Contains(msg, "handshake") ||
|
||||||
|
strings.Contains(msg, "certificate")
|
||||||
|
|
||||||
|
if isTLSError {
|
||||||
|
Debug(msg)
|
||||||
|
} else {
|
||||||
|
Warn(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Fatal logs args at error level and quits with status code 1.
|
// Fatal logs args at error level and quits with status code 1.
|
||||||
func Fatal(args ...any) {
|
func Fatal(args ...any) {
|
||||||
slog.Error(fmt.Sprint(args...))
|
slog.Error(fmt.Sprint(args...))
|
||||||
@@ -138,7 +207,11 @@ func Fatalf(format string, a ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close closes log file descriptor.
|
// Close closes log file descriptor.
|
||||||
|
// Thread-safe: uses mutex to coordinate with Init and DebugLogger.
|
||||||
func Close() {
|
func Close() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
if Log == nil || Log == os.Stderr {
|
if Log == nil || Log == os.Stderr {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,6 +220,8 @@ func Close() {
|
|||||||
msg := fmt.Sprintln("Failed to close log file:", err)
|
msg := fmt.Sprintln("Failed to close log file:", err)
|
||||||
slog.Error(msg)
|
slog.Error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
@@ -354,4 +354,116 @@ func TestCloseStderr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDebugLogger tests that DebugLogger returns a working *log.Logger.
|
||||||
|
func TestDebugLogger(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()
|
||||||
|
if logger == nil {
|
||||||
|
t.Error("DebugLogger returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Print("test debug message")
|
||||||
|
|
||||||
|
content, err := os.ReadFile(logFilePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(content), "test debug message") {
|
||||||
|
t.Error("Expected debug message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: */
|
||||||
|
|||||||
142
llms.txt
142
llms.txt
@@ -30,13 +30,13 @@ curl -u user:pass https://server:8086/archive -o backup.tar.gz
|
|||||||
│ │ ├── globals.go # Global C Config variable
|
│ │ ├── globals.go # Global C Config variable
|
||||||
│ │ ├── archive.go # Common archive logic (createArchiveStream, writeFilesToTar)
|
│ │ ├── archive.go # Common archive logic (createArchiveStream, writeFilesToTar)
|
||||||
│ │ ├── gzip.go # CreateTarGzStream, CreateTarPgzipStream
|
│ │ ├── gzip.go # CreateTarGzStream, CreateTarPgzipStream
|
||||||
│ │ ├── bzip2.go # CreateTarBzip2Stream
|
│ │ ├── bzip2.go # CreateTarBzip2Stream, mapCompressionLevelToBzip2
|
||||||
│ │ ├── zstd.go # CreateTarZstdStream
|
│ │ ├── zstd.go # CreateTarZstdStream, zstdLevel
|
||||||
│ │ ├── lz4.go # CreateTarLz4Stream
|
│ │ ├── lz4.go # CreateTarLz4Stream, mapCompressionLevelToLz4
|
||||||
│ │ ├── xz.go # CreateTarXzStream
|
│ │ ├── xz.go # CreateTarXzStream
|
||||||
│ │ ├── loadConfig.go # HJSON/JSON config parsing with validation
|
│ │ ├── loadConfig.go # HJSON/JSON config parsing with validation
|
||||||
│ │ ├── newServer.go # HTTP server, auth handler, context helpers
|
│ │ ├── newServer.go # HTTP server, auth handler, context helpers
|
||||||
│ │ ├── getFilesFromDirectories.go # Directory walking, exclusion filtering
|
│ │ ├── getFilesFromDirectories.go # Directory walking, exclusion filtering, isExcluded
|
||||||
│ │ └── *_test.go # Test files
|
│ │ └── *_test.go # Test files
|
||||||
│ └── log/main.go # Logging wrapper around stdlib slog
|
│ └── log/main.go # Logging wrapper around stdlib slog
|
||||||
├── contrib/ # Init scripts
|
├── contrib/ # Init scripts
|
||||||
@@ -55,7 +55,7 @@ curl -u user:pass https://server:8086/archive -o backup.tar.gz
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| address | 0.0.0.0 | Listen address |
|
| address | 0.0.0.0 | Listen address |
|
||||||
| port | 8086 | Listen port |
|
| port | 8086 | Listen port |
|
||||||
| cert/key | - | TLS cert/key paths |
|
| cert/key | - | TLS cert/key paths (required when nohttps=false) |
|
||||||
| nohttps | false | Disable HTTPS |
|
| nohttps | false | Disable HTTPS |
|
||||||
| location | /archive | API endpoint path |
|
| location | /archive | API endpoint path |
|
||||||
| user | - | Basic auth username (required) |
|
| user | - | Basic auth username (required) |
|
||||||
@@ -64,10 +64,23 @@ curl -u user:pass https://server:8086/archive -o backup.tar.gz
|
|||||||
| loglevel | info | Log verbosity (debug/info/warn/error) |
|
| loglevel | info | Log verbosity (debug/info/warn/error) |
|
||||||
| directories | - | Directories to backup (required) |
|
| directories | - | Directories to backup (required) |
|
||||||
| backup_timeout | 60 | Stream timeout in minutes (1-1440) |
|
| backup_timeout | 60 | Stream timeout in minutes (1-1440) |
|
||||||
| compression_level | 9 | Gzip level (1-9) |
|
| dir_scan_timeout | 60 | Directory scan timeout in minutes (1-1440) |
|
||||||
|
| compression_level | 9 | Compression level (1-9) |
|
||||||
| exclude_patterns | [] | Regex patterns to exclude from backup |
|
| exclude_patterns | [] | Regex patterns to exclude from backup |
|
||||||
| filename_prefix | backup | Prefix for backup filename in Content-Disposition header |
|
| filename_prefix | backup | Prefix for backup filename in Content-Disposition header |
|
||||||
| compression_algorithm | gzip | Compression algorithm (gzip, pgzip, bzip2, zstd, lz4 or xz) |
|
| default_compression | gzip | Default compression algorithm (gzip, pgzip, bzip2, zstd, lz4, xz) |
|
||||||
|
| compression_algorithm | gzip | Alias for default_compression (deprecated) |
|
||||||
|
|
||||||
|
## Compression Level Mapping
|
||||||
|
|
||||||
|
The `compression_level` config option (1-9) is mapped differently for each algorithm:
|
||||||
|
|
||||||
|
| Algorithm | Level Mapping |
|
||||||
|
|-----------|---------------|
|
||||||
|
| gzip/pgzip/bzip2 | 1-9 directly maps to compression levels |
|
||||||
|
| lz4 | 1-3 → Fastest (0), 4-6 → Default (1), 7-9 → Best (2) |
|
||||||
|
| zstd | 1-3 → Fastest, 4-6 → Default, 7-9 → BetterCompression |
|
||||||
|
| xz | Uses default compression (no configurable level) |
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
@@ -85,35 +98,86 @@ json.Marshal(tmp)
|
|||||||
json.Unmarshal(buf, &C)
|
json.Unmarshal(buf, &C)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stream Cancellation
|
### Context-Aware I/O
|
||||||
Context-aware write and copy functions:
|
Two helper functions in newServer.go handle context cancellation during streaming:
|
||||||
|
- `writeWithContext(ctx, fn)` — checks context before calling fn
|
||||||
|
- `copyWithContext(ctx, dst, src)` — buffered copy (32KB) with cancellation checks, computes MD5 hash
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func writeWithContext(ctx context.Context, fn func() error) error
|
func writeWithContext(ctx context.Context, fn func() error) error
|
||||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, string, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### HTTP Server Middleware
|
||||||
|
Two middleware functions wrap the handler:
|
||||||
|
- `withServerHeader` — adds "Server: backer" header to all responses
|
||||||
|
- `withStatusLogging` — logs 404 and 405 responses
|
||||||
|
|
||||||
|
```go
|
||||||
|
Handler: withServerHeader(withStatusLogging(mux))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unmatched Route Logging
|
||||||
|
The server wraps http.ResponseWriter to capture status codes:
|
||||||
|
- **404 Not Found**: `"Not Found from {clientIP} to {path}"`
|
||||||
|
- **405 Method Not Allowed**: `"Method Not Allowed from {clientIP} for {method} {path}"`
|
||||||
|
|
||||||
|
Uses `responseWriterWrapper` that tracks status code via WriteHeader() method.
|
||||||
|
|
||||||
### Pipe Error Handling
|
### Pipe Error Handling
|
||||||
Uses `pipeErr` tracking to avoid noisy errors when reader closes early.
|
Uses `pipeErr` tracking to avoid noisy errors when reader closes early.
|
||||||
Skips files that can't be read (logs warning, continues backup).
|
Skips files that can't be read (logs warning, continues backup).
|
||||||
|
- `isPipeClosedError()` checks for "io: read/write on closed pipe" errors
|
||||||
|
|
||||||
### Content-Disposition Filename
|
### Content-Disposition Filename
|
||||||
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).
|
- **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)
|
||||||
- **Regular files** are compressed; other file types have no data section.
|
- **Device files** (char/block): included with correct major/minor numbers
|
||||||
|
- **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.
|
||||||
- `copyWithContext` does buffered copy with cancellation checks.
|
- `copyWithContext` does buffered copy with cancellation checks.
|
||||||
|
|
||||||
|
### Client IP Detection
|
||||||
|
Checks `X-Forwarded-For` header first (for proxied requests), then falls back to `RemoteAddr`.
|
||||||
|
|
||||||
|
### TLS Error Handling
|
||||||
|
Uses `ServerWrapper` to intercept and classify errors from Serve() and ServeTLS():
|
||||||
|
- TLS-specific errors logged at debug level to reduce log clutter
|
||||||
|
- Uses errors.As to detect: tls.AlertError, tls.CertificateVerificationError, tls.ECHRejectionError, tls.RecordHeaderError
|
||||||
|
|
||||||
|
### HTTP Routing & Compression Selection
|
||||||
|
The server uses http.NewServeMux() for routing. Multiple routes support different compression formats:
|
||||||
|
```go
|
||||||
|
mux.Handle(C.Location, http.HandlerFunc(backupHandler)) // /archive
|
||||||
|
mux.Handle(C.Location+".tar.gz", http.HandlerFunc(backupHandler)) // archive.tar.gz
|
||||||
|
mux.Handle(C.Location+".tar.xz", http.HandlerFunc(backupHandler)) // archive.tar.xz
|
||||||
|
mux.Handle(C.Location+".tar.bz2", http.HandlerFunc(backupHandler)) // archive.tar.bz2
|
||||||
|
// ... etc (.tar.lz4, .tar.zst, .tar.zstd)
|
||||||
|
```
|
||||||
|
|
||||||
|
Compression algorithm determined by path extension using `path.Ext()`:
|
||||||
|
- Full path: `/archive.tar.xz` → `.xz` → xz
|
||||||
|
- Bare extension: `tar.xz` → `.xz` → xz
|
||||||
|
- Default when no extension matches
|
||||||
|
|
||||||
### Performance Notes
|
### Performance Notes
|
||||||
- Exclude patterns are precompiled after config load for faster matching.
|
- Exclude patterns are precompiled after config load for faster matching.
|
||||||
- Compression level is configurable (1-9); default 9 uses more CPU.
|
- Compression level is configurable (1-9); default 9 uses more CPU.
|
||||||
- Large directory walks use `filepath.WalkDir` (efficient).
|
- Large directory walks use `filepath.WalkDir` (efficient).
|
||||||
|
- Context cancellation checked on each file during directory scan.
|
||||||
|
|
||||||
## Coding Style & Conventions
|
## Coding Style & Conventions
|
||||||
|
|
||||||
@@ -128,7 +192,8 @@ GetFilesFromDirectories()
|
|||||||
// Unexported (private): verbNoun or isNoun pattern
|
// Unexported (private): verbNoun or isNoun pattern
|
||||||
writeWithContext()
|
writeWithContext()
|
||||||
copyWithContext()
|
copyWithContext()
|
||||||
isPipeClosedError() // predicate helper
|
isPipeClosedError()
|
||||||
|
isExcluded()
|
||||||
defaultConfigPath()
|
defaultConfigPath()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,6 +202,7 @@ Struct fields use PascalCase, json tags use snake_case:
|
|||||||
```go
|
```go
|
||||||
BackupTimeout int `json:"backup_timeout"`
|
BackupTimeout int `json:"backup_timeout"`
|
||||||
CompressionLevel int `json:"compression_level"`
|
CompressionLevel int `json:"compression_level"`
|
||||||
|
DirScanTimeout int `json:"dir_scan_timeout"`
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Messages
|
### Error Messages
|
||||||
@@ -149,6 +215,7 @@ CompressionLevel int `json:"compression_level"`
|
|||||||
- Past tense: `"Failed to close gzip writer: %v", err`
|
- Past tense: `"Failed to close gzip writer: %v", err`
|
||||||
- Context included: `"Skipping %s: %v", filename, err`
|
- Context included: `"Skipping %s: %v", filename, err`
|
||||||
- Appropriate level: Warn for recoverable, Error for issues, Fatal for startup failures
|
- Appropriate level: Warn for recoverable, Error for issues, Fatal for startup failures
|
||||||
|
- Log levels: error, warn, info, debug
|
||||||
|
|
||||||
### Config Validation Pattern
|
### Config Validation Pattern
|
||||||
For each config option: set default first, then immediately validate:
|
For each config option: set default first, then immediately validate:
|
||||||
@@ -166,7 +233,7 @@ if C.Port < 1 || C.Port > 65535 {
|
|||||||
|
|
||||||
### Go Formatting
|
### Go Formatting
|
||||||
- Tabs for indentation
|
- Tabs for indentation
|
||||||
- Vim modeline at EOF: `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */`
|
- Vim modeline at EOF: `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|
||||||
### Linter Suppressions (intentional)
|
### Linter Suppressions (intentional)
|
||||||
| Rule | Reason |
|
| Rule | Reason |
|
||||||
@@ -181,24 +248,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 (main and log packages not covered by design)
|
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`
|
||||||
- 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
|
||||||
```
|
```
|
||||||
@@ -208,3 +289,20 @@ Version is injected at build time via ldflags:
|
|||||||
```go
|
```go
|
||||||
var version = "dev" // overridden by: -X main.version=$(VERSION)
|
var version = "dev" // overridden by: -X main.version=$(VERSION)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Request Flow (Good Path)
|
||||||
|
```
|
||||||
|
Request → Method Check (GET) → Auth Check → Get Files (with timeout) → Create Archive → Stream → Complete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Flow (Error Paths)
|
||||||
|
| Request | Response | Logged |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| Non-GET method | 405 Method Not Allowed | Yes (info) |
|
||||||
|
| No auth | 401 Unauthorized | Yes (info) |
|
||||||
|
| Wrong credentials | 401 Unauthorized | Yes (info) |
|
||||||
|
| Unknown path | 404 Not Found | Yes (info) |
|
||||||
|
| Dir scan timeout | 504 Request Timeout | Yes (error) |
|
||||||
|
| Failed to get files | 500 Internal Server Error | Yes (error) |
|
||||||
|
| Client disconnect | - | Yes (info) |
|
||||||
|
| Backup completed | 200 OK | Yes (info) |
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Fucked up beyond recognotion.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
My dear Tweety,
|
|
||||||
I'd like to greet you as my breakfast.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
This is MY file!
|
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
/* By default listen on port 8086, like first intel commercially successful cpu model. */
|
/* By default listen on port 8086, like first intel commercially successful cpu model. */
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
|
|
||||||
/* Path location of certificate file */
|
/* Path location of test certificate file */
|
||||||
"cert": "../../test_data/example.crt",
|
"cert": "../../test_data/example.crt",
|
||||||
|
|
||||||
/* Path location of certificate key */
|
/* Path location of test certificate key */
|
||||||
"key": "../../test_data/example.key",
|
"key": "../../test_data/example.key",
|
||||||
|
|
||||||
/* We really want to use https for backup archive tranferring. */
|
/* We really want to use https for backup archive tranferring. */
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"password": "SharpShooter",
|
"password": "SharpShooter",
|
||||||
|
|
||||||
/* Where to store logs. */
|
/* Where to store logs. */
|
||||||
"log": "/var/log/backer.log",
|
"log": "../../test_data/tmp/backer.log",
|
||||||
|
|
||||||
/* Verbosity of logs */
|
/* Verbosity of logs */
|
||||||
"loglevel": "info",
|
"loglevel": "info",
|
||||||
@@ -39,5 +39,5 @@
|
|||||||
"backup_timeout": 60,
|
"backup_timeout": 60,
|
||||||
|
|
||||||
/* Gzip compression level (1-9, where 9 is best compression). */
|
/* Gzip compression level (1-9, where 9 is best compression). */
|
||||||
"compression_level": 6
|
"compression_level": 3
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user