Compare commits
46 Commits
3e246da9c5
...
v0.9
| 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 | ||
|
|
8beba095d0 | ||
|
|
afffc99a8a | ||
|
|
6201b03230 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
# Binary
|
||||
/backer
|
||||
/backer.exe
|
||||
/cmd/backer/backer
|
||||
/cmd/backer/backer.exe
|
||||
|
||||
# Local config (may contain secrets)
|
||||
/config.json
|
||||
@@ -27,3 +29,7 @@ Thumbs.db
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Test data (generated by tests)
|
||||
test_data/tmp
|
||||
test_data/test1
|
||||
|
||||
@@ -74,6 +74,7 @@ linters:
|
||||
- paramTypeCombine
|
||||
- whyNoLint
|
||||
- hugeParam
|
||||
- unnamedResult
|
||||
godoclint:
|
||||
default: all
|
||||
ignore-unexported: false
|
||||
|
||||
170
AGENTS.md
170
AGENTS.md
@@ -4,17 +4,58 @@
|
||||
|
||||
Backer is a Go-based HTTP/HTTPS backup server that creates compressed archives on-the-fly from configured directories. Supports multiple compression algorithms: gzip, pgzip, bzip2, zstd, lz4, xz. It serves a single endpoint that clients (e.g., `curl`) can query to download a timestamped backup archive. Licensed under MIT.
|
||||
|
||||
## Fundamental Rules
|
||||
|
||||
- **Do not apply changes without explicit user consent.** When asked to analyze, review, or suggest any changes, present your proposal first and wait for confirmation before editing any files. Always ask "Should I implement this?" or "Should I apply these changes?" before modifying anything.
|
||||
- **Keep codebase simple, small, maintainable, safe and human-readable.** Favour minimal, clear solutions over clever or abstract ones. Prefer clarity over cleverness. Prioritize explicitness. Minimize magic. Do not introduce patterns, layers, or indirection unless they directly solve a concrete problem. Every line of code must earn its place.
|
||||
- **Create small focused modules and separate functionality over files.** Do not make universal implementation; try to move functions to separate files and name files after function names.
|
||||
- **Use a consistent structure.** Subsequent searches will be faster and consume less context.
|
||||
- **Avoid duplicate names.** This is not strict rule. If codebase contain multiple files with the same name (e.g., index.ts), we have to read them all to discover the one exactly we need. This consumes time and context.
|
||||
- **When stuck, ask clarification questions, or present a short plan, or both.** Do not try to guess intent or make assumptions - ask.
|
||||
- **Always make TODO plan before any job and follow it closely.**
|
||||
- **Report any found defects, issues, bugs.** And keep track of them.
|
||||
- **Comment the code.** Add code comments to explain corner cases, complex logic, business rules, and design decisions. Focus on **what** problem the code is solving rather than **how** it is solving it. Write comments in easy to read and understand language, for humans to comprehend. Every public API and structure field must have a docstring.
|
||||
- **Use domain names.** Use naming conventions that reflect their purpose and domain concepts, not their technical implementation (e.g., OrderProcessor instead of OrderServiceFactory).
|
||||
- **Test corner cases.** Write comprehensive test suites that cover various scenarios and edge cases.
|
||||
- **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.**
|
||||
- **Remove dead code.** A half-finished refactoring can actually harm the contributions.
|
||||
- **Update AGENTS.md automatically.** Keep AGENTS.md dense, detailed and concise.
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
- **Do not apply changes without explicit user consent.** When asked to analyze, review, or suggest improvements, present your proposal first and wait for confirmation before editing any files. Always ask "Should I implement this?" or "Should I apply these changes?" before writing code.
|
||||
- **Keep the codebase simple, small, maintainable, safe, and human-readable.** Favor minimal, clear solutions over clever or abstract ones. Do not introduce patterns, layers, or indirection unless they directly solve a concrete problem. Every line of code must earn its place.
|
||||
- **When stuck, ask a clarification question, or propose a short plan, or both.** Do not guess intent or make assumptions — ask.
|
||||
- **Always make a TODO plan before acting. And try to follow this plan.**
|
||||
- **If you notice defects, bugs, or issues, always report it.**
|
||||
- **Track new features, known issues, bugs, and other defects.** Update documentation as you implement features, fix issues, fix bugs, or discover new ones.
|
||||
- **Update `AGENTS.md` as the project evolves.** Keep it comprehensive but dense.
|
||||
- **Don't use `context` package unless absolutely necessary.** Try to find another solution first.
|
||||
- **Create unit tests if possible/necessary** to verify changes that are not covered by unit tests.
|
||||
- **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
|
||||
|
||||
@@ -113,6 +154,18 @@ CompressionLevel int `json:"compression_level"`
|
||||
| QF1001 | De Morgan's law disabled for auth condition clarity |
|
||||
| revive (error strings) | Same reason as ST1005 |
|
||||
|
||||
### Design Decisions (Not Issues)
|
||||
|
||||
The following are intentional design decisions, not bugs or defects:
|
||||
|
||||
- **No rate limiting**: Backups are assumed to be infrequent enough that rate limiting is unnecessary.
|
||||
- **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
|
||||
|
||||
```
|
||||
@@ -184,10 +237,12 @@ Config is JSON (HJSON with comments supported). Default path depends on OS:
|
||||
| loglevel | `info` | Log verbosity: error/warn/info/debug |
|
||||
| directories | — | Directories to backup (required, validated for existence) |
|
||||
| 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) |
|
||||
| exclude_patterns | `[]` | Regex patterns to exclude from backup |
|
||||
| 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 or xz |
|
||||
| compression_algorithm | `gzip` | Alias for default_compression (deprecated) |
|
||||
|
||||
## Architecture & Key Patterns
|
||||
|
||||
@@ -216,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:
|
||||
- `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
|
||||
|
||||
- **Regular files**: streamed into tar archive with content
|
||||
- **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
|
||||
- **Named pipes**: included (header only, no data)
|
||||
- **Named pipes (FIFOs)**: included (header only, no data)
|
||||
- **Sockets**: skipped (not archivable)
|
||||
- **Hard links**: deduplicated — if multiple files share the same inode, only the first is stored with content, subsequent hard links are stored as link entries pointing to the original
|
||||
- **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
|
||||
|
||||
@@ -233,7 +309,7 @@ Two helper functions in `newServer.go` handle context cancellation during stream
|
||||
|
||||
### 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
|
||||
|
||||
@@ -243,8 +319,66 @@ Checks `X-Forwarded-For` header first (for proxied requests), then falls back to
|
||||
|
||||
- `ReadHeaderTimeout`: 5 seconds
|
||||
- `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 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
|
||||
|
||||
- `github.com/hjson/hjson-go` v3.3.0 — HJSON config parsing
|
||||
@@ -275,10 +409,14 @@ Tests live alongside source files as `*_test.go` (package name, not `_test` suff
|
||||
|
||||
### Test Data
|
||||
|
||||
- `test_data/test_config.json` — valid test config (user: JohnnyGoode, password: SharpShooter)
|
||||
- `test_data/test1/foo/` — test directory with files, subdirs, empty dirs
|
||||
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/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
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -23,13 +23,15 @@ build:
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf test_data/tmp
|
||||
rm -rf test_data/test1
|
||||
|
||||
upgrade:
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
|
||||
test:
|
||||
test: clean
|
||||
go test ./...
|
||||
|
||||
help:
|
||||
|
||||
104
README.md
104
README.md
@@ -28,10 +28,22 @@ If you running FreeBSD, use the rc.d script `contrib/backer.freebsd` and place i
|
||||
Build, install and configure backer. On your backup storage server you can do
|
||||
|
||||
```bash
|
||||
curl -u username:password https://your_server:8086/archive -o "backup-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive
|
||||
```
|
||||
|
||||
The filename and extension depend on your `filename_prefix` and `compression_algorithm` settings.
|
||||
The filename and extension depend on your `filename_prefix` and `default_compression` settings.
|
||||
|
||||
You can also override compression by specifying the archive extension in the URL:
|
||||
|
||||
```bash
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive.xz
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive.gz # Uses pgzip
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive.bz2
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive.zst
|
||||
wget --quiet --no-check-certificate --content-disposition --http-user=username --http-password=password https://your_server:8086/archive.lz4
|
||||
```
|
||||
|
||||
Note: `/archive.tar.gz` always uses pgzip (parallel gzip) for better performance.
|
||||
|
||||
Of course, something can go wrong, so you must check http status code after downloading backup.
|
||||
|
||||
@@ -53,23 +65,25 @@ All options described in example config.
|
||||
| loglevel | "info" | Verbosity of logs. | Options: error, warn, info, debug. |
|
||||
| directories | — | Directories to backup. | Required. Array of paths. |
|
||||
| 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). |
|
||||
| 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`. |
|
||||
| compression_algorithm | "gzip" | Compression algorithm. | Options: gzip, pgzip, bzip2, zstd, lz4, xz. |
|
||||
| default_compression | "gzip" | Default compression algorithm. | Options: gzip, pgzip, bzip2, zstd, lz4, xz. |
|
||||
| compression_algorithm | "gzip" | Alias for default_compression. | You should use default_compression instead. |
|
||||
|
||||
## Compression algorithms
|
||||
|
||||
AI suggests this table as expected performance.
|
||||
|
||||
| Algorithm | Speed | Compression | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| gzip | Fast | Good | Default. Standard, widely compatible. |
|
||||
| pgzip | Fast | Good | Parallel gzip. Uses multiple CPU cores, slightly larger files. |
|
||||
| bzip2 | Slow | Better | Pure Go. Good compression, slower than gzip. |
|
||||
| zstd | Fast | Very good | Good balance of speed and compression. |
|
||||
| lz4 | Fastest | Moderate | Extremely fast, larger files. |
|
||||
| xz | Slowest | Best | Best compression, slowest speed. |
|
||||
| Algorithm | Speed | Compression | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| gzip | Fast | Good | Standard, widely compatible. |
|
||||
| pgzip | Fast | Good | Parallel gzip. Uses multiple CPU cores, slightly larger files. |
|
||||
| bzip2 | Slow | Better | Pure Go. Good compression, slower than gzip. |
|
||||
| zstd | Fast | Very good | Good balance of speed and compression. |
|
||||
| lz4 | Fastest | Moderate | Extremely fast, larger files. |
|
||||
| xz | Slowest | Best | Best compression, slowest speed. |
|
||||
|
||||
In real live with backup of 1 lxc container inside vm powered by 1 core cpu, 768Mb RAM and 100Mbit non-guaranteed
|
||||
network bandwidth, located in near by country I've got these results
|
||||
@@ -77,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 |
|
||||
| :--- | ---: | ---: | ---: | ---: | :--- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| gzip | 816734188 | 14m:30s | 96% | 18+6 | stdlib compression/gzip |
|
||||
| bzip2 | 788061284 | 7m:16s | 92-94% | 43+6 | dsnet/compress/bzip2 |
|
||||
| xz | 512004612 | 13m:53s | 96% | 116+6 | ulikunitz/xz |
|
||||
|
||||
The `compression_level` option (1-9) maps 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) |
|
||||
|
||||
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
|
||||
@@ -98,6 +121,55 @@ good enough cypher.
|
||||
|
||||
And one more thing user and password are mandatory, we should have at least some minimal barriers, right?
|
||||
|
||||
## Supported File Types
|
||||
|
||||
Backer handles various file types when creating archives:
|
||||
|
||||
- **Regular files**: Included with full content
|
||||
- **Directories**: Included as directory entries
|
||||
- **Symlinks**: Stored as symlink entries (target path preserved)
|
||||
- **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
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -40,10 +41,14 @@ func main() {
|
||||
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 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)
|
||||
}
|
||||
} else {
|
||||
@@ -55,10 +60,12 @@ func main() {
|
||||
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.Infof("Server stopped")
|
||||
}
|
||||
|
||||
// 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: */
|
||||
@@ -7,11 +7,11 @@ pidfile="/run/$SVCNAME/$SVCNAME.pid"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
use dns
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
mkdir -p /var/log/$SVCNAME
|
||||
mkdir -p /run/$SVCNAME
|
||||
}
|
||||
|
||||
# vim: set ft=sh noet ai ts=4 sw=4 sts=4:
|
||||
# vim: set ft=sh noet ai ts=4 sw=4 sts=4:
|
||||
@@ -26,27 +26,33 @@
|
||||
/* Where to store logs. */
|
||||
"log": "/var/log/backer.log",
|
||||
|
||||
/* Verbosity of logs */
|
||||
/* Verbosity of logs. */
|
||||
"loglevel": "info",
|
||||
|
||||
/* Diretories to backup */
|
||||
"directories": [
|
||||
"/path/to/directory1",
|
||||
"/path/to/directory2"
|
||||
],
|
||||
|
||||
/* Backup streaming timeout in minutes. */
|
||||
"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": 9,
|
||||
|
||||
/* Regex patterns to exclude from backup. */
|
||||
"exclude_patterns": [],
|
||||
|
||||
/* Prefix for backup filename in Content-Disposition header. */
|
||||
"filename_prefix": "backup",
|
||||
|
||||
/* Compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz. */
|
||||
"compression_algorithm": "gzip"
|
||||
/* Compression algorithm alias (deprecated: use default_compression). */
|
||||
"compression_algorithm": "gzip",
|
||||
|
||||
/* Default compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz. */
|
||||
"default_compression": "gzip",
|
||||
|
||||
/* Regex patterns to exclude from backup. */
|
||||
"exclude_patterns": [],
|
||||
|
||||
/* Maximum allowed time, spent for getting file list from backupped directories. */
|
||||
"dir_scan_timeout": 60,
|
||||
|
||||
/* Directories to backup. */
|
||||
"directories": [
|
||||
"/path/to/directory1",
|
||||
"/path/to/directory2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"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.
|
||||
// It tracks inodes to handle hard links - if a file has already been archived,
|
||||
// subsequent hard links to the same inode are stored as link entries.
|
||||
// This is the Unix implementation (syscall not available on Windows).
|
||||
func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, closePipeWithError func(error)) {
|
||||
// Track inodes to handle hard links: inode -> first path added.
|
||||
// Uses simple file path tracking on Windows, inode tracking on Unix.
|
||||
seenInodes := make(map[uint64]string)
|
||||
|
||||
for _, fpath := range filepaths {
|
||||
var (
|
||||
err error
|
||||
linkTarget string
|
||||
isSymlink bool
|
||||
isHardLink bool
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -99,6 +109,7 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle symlinks - read target path and mark as symbolic link.
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
linkTarget, err = os.Readlink(fpath)
|
||||
|
||||
@@ -107,6 +118,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
isSymlink = true
|
||||
}
|
||||
|
||||
// Skip sockets explicitly (they cannot be backed up).
|
||||
@@ -116,6 +129,25 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for hard link - if inode was already seen, store as link instead of content.
|
||||
// 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)
|
||||
|
||||
if err != nil {
|
||||
@@ -124,6 +156,15 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
||||
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.Name = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(fpath)), "/")
|
||||
|
||||
@@ -137,8 +178,8 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl
|
||||
return
|
||||
}
|
||||
|
||||
// Only stream file contents for regular files.
|
||||
if mode.IsRegular() {
|
||||
// Only stream file contents for regular files that are not hard links.
|
||||
if mode.IsRegular() && linkTarget == "" {
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
err = copyWithContext(ctx, tw, f)
|
||||
_, _, err = copyWithContext(ctx, tw, f)
|
||||
|
||||
if e := f.Close(); e != nil {
|
||||
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.
|
||||
func isPipeClosedError(err error) bool {
|
||||
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.
|
||||
func CreateTarBzip2Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||
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: */
|
||||
|
||||
@@ -11,6 +11,12 @@ const (
|
||||
// copyBufferSize is the buffer size used for streaming file contents.
|
||||
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 = 1
|
||||
|
||||
@@ -23,6 +29,12 @@ const (
|
||||
// backupTimeoutMax is the maximum backup timeout in minutes (24 hours).
|
||||
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 = 1
|
||||
|
||||
@@ -35,6 +47,9 @@ const (
|
||||
// defaultBackupTimeout is the default backup timeout in minutes.
|
||||
defaultBackupTimeout = 60
|
||||
|
||||
// defaultDirScanTimeout is the default directory scan timeout in minutes (1 hour).
|
||||
defaultDirScanTimeout = 60
|
||||
|
||||
// defaultCompressionLevel is the default compression level.
|
||||
defaultCompressionLevel = 9
|
||||
|
||||
@@ -50,8 +65,8 @@ const (
|
||||
// defaultFilenamePrefix is the default prefix for backup filenames.
|
||||
defaultFilenamePrefix = "backup"
|
||||
|
||||
// defaultCompressionAlgorithm is the default compression algorithm.
|
||||
defaultCompressionAlgorithm = "gzip"
|
||||
// defaultDefaultCompression is the default compression algorithm.
|
||||
defaultDefaultCompression = "gzip"
|
||||
)
|
||||
|
||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"backer/internal/log"
|
||||
backerlog "backer/internal/log"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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
|
||||
|
||||
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
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Skipping %s: %v", path, err)
|
||||
backerlog.Warnf("Skipping %s: %v", path, err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExcluded(path) {
|
||||
log.Debugf("Excluding: %s", path)
|
||||
backerlog.Debugf("Excluding: %s", path)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -45,7 +67,13 @@ func GetFilesFromDirectories(directories []string) ([]string, error) {
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -13,30 +15,47 @@ func normalizePath(p string) string {
|
||||
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.
|
||||
func TestGetFilesFromDirectories(t *testing.T) {
|
||||
// Ensure empty_dir exists for the test.
|
||||
os.MkdirAll("../../test_data/test1/foo/empty_dir", 0755)
|
||||
testDataPath := "../../test_data"
|
||||
|
||||
// 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 (
|
||||
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{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/foo/empty_dir",
|
||||
"../../test_data/test1/foo/hello_breakfast.txt",
|
||||
"../../test_data/test1/foo/mydir",
|
||||
"../../test_data/test1/foo/mydir/myfile.txt",
|
||||
"../../test_data/test1/foo/some_text.txt",
|
||||
"../../test_data/test1/bar",
|
||||
"../../test_data/test1/bar/goodbye.txt",
|
||||
"../../test_data/test1/bar/goodbye.txt/text.txt",
|
||||
"../../test_data/test1/bar/hello.txt",
|
||||
filepath.Join(testDataPath, "test1", "foo"),
|
||||
filepath.Join(testDataPath, "test1", "foo", "empty_dir"),
|
||||
filepath.Join(testDataPath, "test1", "foo", "hello_breakfast.txt"),
|
||||
filepath.Join(testDataPath, "test1", "foo", "mydir"),
|
||||
filepath.Join(testDataPath, "test1", "foo", "mydir", "myfile.txt"),
|
||||
filepath.Join(testDataPath, "test1", "foo", "some_text.txt"),
|
||||
filepath.Join(testDataPath, "test1", "bar"),
|
||||
filepath.Join(testDataPath, "test1", "bar", "goodbye.txt"),
|
||||
filepath.Join(testDataPath, "test1", "bar", "goodbye.txt", "text.txt"),
|
||||
filepath.Join(testDataPath, "test1", "bar", "hello.txt"),
|
||||
}
|
||||
)
|
||||
|
||||
output, err := GetFilesFromDirectories(input.Directories)
|
||||
output, err := GetFilesFromDirectories(context.Background(), input.Directories)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -51,7 +70,8 @@ func TestGetFilesFromDirectories(t *testing.T) {
|
||||
|
||||
for _, filepath := range output {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +79,8 @@ func TestGetFilesFromDirectories(t *testing.T) {
|
||||
|
||||
// TestGetFilesFromDirectoriesSingleDir tests with a single directory.
|
||||
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 {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -70,7 +91,7 @@ func TestGetFilesFromDirectoriesSingleDir(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -80,7 +101,7 @@ func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
||||
// Create temp empty dir
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -93,23 +114,48 @@ func TestGetFilesFromDirectoriesEmptyDir(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetFilesFromDirectoriesNonExistent tests with non-existent directory.
|
||||
// Should log error but not fail - returns empty list.
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
||||
output, err := GetFilesFromDirectories([]string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
testDataPath := filepath.Join("../../test_data")
|
||||
output, err := GetFilesFromDirectories(context.Background(), []string{
|
||||
filepath.Join(testDataPath, "test1", "foo"),
|
||||
filepath.Join(testDataPath, "test1", "bar"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -118,10 +164,10 @@ func TestGetFilesFromDirectoriesMultipleDirs(t *testing.T) {
|
||||
|
||||
// Should contain entries from both directories
|
||||
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 {
|
||||
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 {
|
||||
@@ -149,7 +195,7 @@ func TestGetFilesFromDirectoriesSymlink(t *testing.T) {
|
||||
t.Skip("Symlinks not supported, skipping test")
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -228,7 +274,7 @@ func TestGetFilesFromDirectoriesWithExcludes(t *testing.T) {
|
||||
regexp.MustCompile(`\.tmp$`),
|
||||
}
|
||||
|
||||
output, err := GetFilesFromDirectories([]string{tmpDir})
|
||||
output, err := GetFilesFromDirectories(context.Background(), []string{tmpDir})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
if C.CompressionLevel == 0 {
|
||||
C.CompressionLevel = defaultCompressionLevel
|
||||
@@ -135,15 +146,20 @@ func LoadConfig(path string) error {
|
||||
log.Warnf("Config option filename_prefix is not set, fallback to %s", C.FilenamePrefix)
|
||||
}
|
||||
|
||||
// CompressionAlgorithm: default and validate.
|
||||
if C.CompressionAlgorithm == "" {
|
||||
C.CompressionAlgorithm = defaultCompressionAlgorithm
|
||||
|
||||
log.Warnf("Config option compression_algorithm is not set, fallback to %s", C.CompressionAlgorithm)
|
||||
// DefaultCompression: default and validate. If compression_algorithm is set (deprecated), use it as fallback.
|
||||
if C.DefaultCompression == "" {
|
||||
if C.CompressionAlgorithm != "" {
|
||||
C.DefaultCompression = C.CompressionAlgorithm
|
||||
} else {
|
||||
C.DefaultCompression = defaultDefaultCompression
|
||||
}
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "gzip" && C.CompressionAlgorithm != "bzip2" && C.CompressionAlgorithm != "zstd" && C.CompressionAlgorithm != "lz4" && C.CompressionAlgorithm != "xz" && C.CompressionAlgorithm != "pgzip" {
|
||||
return fmt.Errorf("Config option compression_algorithm must be gzip, bzip2, zstd, lz4, xz or pgzip, got %s", C.CompressionAlgorithm)
|
||||
switch C.DefaultCompression {
|
||||
case "gzip", "pgzip", "bzip2", "xz", "lz4", "zstd":
|
||||
// No-op: compression algorithm validated.
|
||||
default:
|
||||
return fmt.Errorf("Config option default_compression must be gzip, bzip2, zstd, lz4, xz or pgzip, got %s", C.DefaultCompression)
|
||||
}
|
||||
|
||||
// Compile exclude patterns.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package backer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
@@ -10,18 +12,50 @@ func TestLoadConfig(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
|
||||
var (
|
||||
testConfigPath = "./../../test_data/test_config.json"
|
||||
expectedData = Config{
|
||||
Address: "127.0.0.1",
|
||||
Port: 8086,
|
||||
Directories: []string{
|
||||
"../../test_data/test1/foo",
|
||||
"../../test_data/test1/bar",
|
||||
},
|
||||
Location: "/archive",
|
||||
}
|
||||
)
|
||||
testDataPath := filepath.Join(t.TempDir(), "test_data")
|
||||
if err := os.MkdirAll(filepath.Join(testDataPath, "test1", "foo"), 0755); err != nil {
|
||||
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",
|
||||
Port: 8086,
|
||||
Directories: []string{
|
||||
filepath.Join(testDataPath, "test1", "foo"),
|
||||
filepath.Join(testDataPath, "test1", "bar"),
|
||||
},
|
||||
Location: "/archive",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
NoHTTPS: true,
|
||||
}
|
||||
|
||||
err := LoadConfig(testConfigPath)
|
||||
|
||||
@@ -57,6 +91,8 @@ func TestLoadConfig(t *testing.T) {
|
||||
|
||||
// TestLoadConfigMissingUser tests that config fails when user is missing.
|
||||
func TestLoadConfigMissingUser(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"password": "test",
|
||||
@@ -72,6 +108,8 @@ func TestLoadConfigMissingUser(t *testing.T) {
|
||||
|
||||
// TestLoadConfigMissingPassword tests that config fails when password is missing.
|
||||
func TestLoadConfigMissingPassword(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
@@ -87,6 +125,8 @@ func TestLoadConfigMissingPassword(t *testing.T) {
|
||||
|
||||
// TestLoadConfigMissingDirectories tests that config fails when directories is missing.
|
||||
func TestLoadConfigMissingDirectories(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
@@ -102,6 +142,8 @@ func TestLoadConfigMissingDirectories(t *testing.T) {
|
||||
|
||||
// TestLoadConfigInvalidPort tests that config fails with invalid port.
|
||||
func TestLoadConfigInvalidPort(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 99999,
|
||||
"user": "test",
|
||||
@@ -118,6 +160,8 @@ func TestLoadConfigInvalidPort(t *testing.T) {
|
||||
|
||||
// TestLoadConfigNonExistentDirectory tests that config fails with non-existent directory.
|
||||
func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
@@ -134,6 +178,8 @@ func TestLoadConfigNonExistentDirectory(t *testing.T) {
|
||||
|
||||
// TestLoadConfigHTTPSWithoutCert tests that config fails when HTTPS enabled but no cert.
|
||||
func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
@@ -151,6 +197,8 @@ func TestLoadConfigHTTPSWithoutCert(t *testing.T) {
|
||||
|
||||
// TestLoadConfigNoHTTPS tests that config succeeds with nohttps=true and no cert.
|
||||
func TestLoadConfigNoHTTPS(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"port": 8086,
|
||||
"user": "test",
|
||||
@@ -172,6 +220,8 @@ func TestLoadConfigNoHTTPS(t *testing.T) {
|
||||
|
||||
// TestLoadConfigFilenamePrefixCustom tests custom filename prefix.
|
||||
func TestLoadConfigFilenamePrefixCustom(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -193,6 +243,8 @@ func TestLoadConfigFilenamePrefixCustom(t *testing.T) {
|
||||
|
||||
// TestLoadConfigDefaultValues tests default values are set correctly.
|
||||
func TestLoadConfigDefaultValues(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -227,13 +279,15 @@ func TestLoadConfigDefaultValues(t *testing.T) {
|
||||
t.Errorf("C.FilenamePrefix should default to backup, got %s", C.FilenamePrefix)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "gzip" {
|
||||
t.Errorf("C.CompressionAlgorithm should default to gzip, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "gzip" {
|
||||
t.Errorf("C.DefaultCompression should default to gzip, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmBzip2 tests bzip2 compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmBzip2(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -248,13 +302,15 @@ func TestLoadConfigCompressionAlgorithmBzip2(t *testing.T) {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "bzip2" {
|
||||
t.Errorf("C.CompressionAlgorithm should be bzip2, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "bzip2" {
|
||||
t.Errorf("C.DefaultCompression should be bzip2, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmZstd tests zstd compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -269,13 +325,15 @@ func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "zstd" {
|
||||
t.Errorf("C.CompressionAlgorithm should be zstd, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "zstd" {
|
||||
t.Errorf("C.DefaultCompression should be zstd, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmLz4 tests lz4 compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -290,13 +348,15 @@ func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "lz4" {
|
||||
t.Errorf("C.CompressionAlgorithm should be lz4, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "lz4" {
|
||||
t.Errorf("C.DefaultCompression should be lz4, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmXz tests xz compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmXz(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -311,13 +371,15 @@ func TestLoadConfigCompressionAlgorithmXz(t *testing.T) {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "xz" {
|
||||
t.Errorf("C.CompressionAlgorithm should be xz, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "xz" {
|
||||
t.Errorf("C.DefaultCompression should be xz, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmPgzip tests pgzip compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmPgzip(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -332,13 +394,15 @@ func TestLoadConfigCompressionAlgorithmPgzip(t *testing.T) {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if C.CompressionAlgorithm != "pgzip" {
|
||||
t.Errorf("C.CompressionAlgorithm should be pgzip, got %s", C.CompressionAlgorithm)
|
||||
if C.DefaultCompression != "pgzip" {
|
||||
t.Errorf("C.DefaultCompression should be pgzip, got %s", C.DefaultCompression)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigCompressionAlgorithmInvalid tests invalid compression algorithm.
|
||||
func TestLoadConfigCompressionAlgorithmInvalid(t *testing.T) {
|
||||
// Reset global config
|
||||
C = Config{}
|
||||
config := `{
|
||||
"user": "test",
|
||||
"password": "test",
|
||||
@@ -367,7 +431,6 @@ func writeTempConfig(t *testing.T, content string) string {
|
||||
tmpFile.Close()
|
||||
// Reset global config before loading
|
||||
C = Config{}
|
||||
excludePatterns = nil
|
||||
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.
|
||||
func CreateTarLz4Stream(ctx context.Context, filepaths []string) io.ReadCloser {
|
||||
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: */
|
||||
|
||||
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,17 +2,69 @@ package backer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
func withServerHeader(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -22,7 +74,8 @@ func withServerHeader(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
if err := LoadConfig(configPath); err != nil {
|
||||
@@ -33,9 +86,18 @@ func NewServer(configPath string) (*http.Server, error) {
|
||||
log.Errorf("Failed to initialize logger: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("Using %s as default compression algorithm", C.DefaultCompression)
|
||||
|
||||
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()
|
||||
|
||||
// No auth? Good bye.
|
||||
@@ -43,16 +105,18 @@ func NewServer(configPath string) (*http.Server, error) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
log.Infof("Access denied from %s to %s (no username or password given)", r.RemoteAddr, r.RequestURI)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Both username AND password must match. Fuck De Morgan and his bullshit laws - we explicitly require
|
||||
// that the whole condition fails. That *is* the point.
|
||||
//nolint:staticcheck // QF1001: De Morgan's law is irrelevant here.
|
||||
if !(username == C.User && password == C.Password) {
|
||||
// Both username AND password must match. Use timing-safe comparison to prevent timing attacks.
|
||||
if !timingSafeMatch(username, C.User) || !timingSafeMatch(password, C.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
|
||||
log.Infof("Access denied from %s to %s (username and/or password incorrect)", r.RemoteAddr, r.RequestURI)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,31 +125,56 @@ func NewServer(configPath string) (*http.Server, error) {
|
||||
|
||||
log.Infof("Backup started: client=%s user=%s", clientIP, username)
|
||||
|
||||
ctx := r.Context()
|
||||
files, err := GetFilesFromDirectories(C.Directories)
|
||||
// Create context with dir_scan_timeout from request context.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(C.DirScanTimeout)*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
files, err := GetFilesFromDirectories(ctx, C.Directories)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get files: %v", err)
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
w.Header().Set("Server", "backer")
|
||||
http.Error(w, "Directory scan timed out", http.StatusRequestTimeout)
|
||||
|
||||
w.Header().Set("Server", "backer")
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
log.Errorf("Directory scan timed out: %v", err)
|
||||
|
||||
default:
|
||||
log.Errorf("Failed to get files: %v", err)
|
||||
|
||||
w.Header().Set("Server", "backer")
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
log.Warnf("No files found to backup in configured directories")
|
||||
}
|
||||
|
||||
var (
|
||||
archive io.ReadCloser
|
||||
extension string
|
||||
algorithm string
|
||||
)
|
||||
|
||||
switch C.CompressionAlgorithm {
|
||||
requestedExt := r.URL.Path
|
||||
|
||||
algorithm = getCompressionAlgorithm(requestedExt)
|
||||
|
||||
switch algorithm {
|
||||
case "bzip2":
|
||||
archive = CreateTarBzip2Stream(ctx, files)
|
||||
extension = "tar.bz2"
|
||||
|
||||
case "zstd":
|
||||
archive = CreateTarZstdStream(ctx, files)
|
||||
extension = "tar.zst"
|
||||
if strings.HasSuffix(requestedExt, ".zst") {
|
||||
extension = "tar.zst"
|
||||
} else {
|
||||
extension = "tar.zstd"
|
||||
}
|
||||
|
||||
case "lz4":
|
||||
archive = CreateTarLz4Stream(ctx, files)
|
||||
@@ -110,55 +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)
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
bytesWritten, err := io.Copy(w, archive)
|
||||
duration := time.Since(backupStart)
|
||||
|
||||
bytesWritten, md5Hash, err := copyWithContext(ctx, w, archive)
|
||||
if err != nil {
|
||||
duration := time.Since(backupStart)
|
||||
|
||||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d duration=%s", clientIP, username, len(files), bytesWritten, duration)
|
||||
})
|
||||
|
||||
// Decision about which mode (http or https) to run made in cmd/backer/main.go, where we run server.ListenAndServe()
|
||||
// or server.ListenAndServeTLS() depending on value of C.NoHTTPS.
|
||||
if C.NoHTTPS {
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(mux),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
}, nil
|
||||
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))
|
||||
}
|
||||
|
||||
// Decision about which mode (http or https) to run made in cmd/backer/main.go, where we run server.ListenAndServe()
|
||||
// or server.ListenAndServeTLS() depending on value of C.NoHTTPS.
|
||||
if C.NoHTTPS {
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(mux),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
}, nil
|
||||
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))
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
// HTTP mode if nohttps is enabled in config.
|
||||
// HTTPS mode if nohttps is disabled in config.
|
||||
if C.NoHTTPS {
|
||||
srv := &ServerWrapper{
|
||||
Server: &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(withStatusLogging(mux)),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
ErrorLog: log.DebugLogger(),
|
||||
},
|
||||
}
|
||||
srv.SetKeepAlivesEnabled(false)
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
srv := &ServerWrapper{
|
||||
Server: &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", C.Address, C.Port),
|
||||
Handler: withServerHeader(withStatusLogging(mux)),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: time.Duration(C.BackupTimeout) * time.Minute,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
ErrorLog: log.DebugLogger(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
srv.SetKeepAlivesEnabled(false)
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// writeWithContext checks if context is canceled before executing fn.
|
||||
@@ -174,31 +288,50 @@ func writeWithContext(ctx context.Context, fn func() error) error {
|
||||
}
|
||||
|
||||
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
||||
// context cancellation. If the context is canceled, the copy stops and returns
|
||||
// ctx.Err().
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error {
|
||||
// context cancellation. Returns bytes written, MD5 hash (hex string), and any error encountered.
|
||||
//
|
||||
//nolint:gosec
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, string, error) {
|
||||
buf := make([]byte, copyBufferSize)
|
||||
|
||||
var bytesWritten int64
|
||||
|
||||
hasher := md5.New()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return bytesWritten, "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := src.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
select {
|
||||
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 == io.EOF {
|
||||
return nil
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return bytesWritten, hash, nil
|
||||
}
|
||||
|
||||
return readErr
|
||||
return bytesWritten, "", readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,4 +351,102 @@ func getClientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// 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.
|
||||
// For .tar.gz extension, pgzip is always used for parallel compression.
|
||||
// The input can be either a full path like "/archive.tar.xz" or just the extension like "tar.xz" or "xz".
|
||||
func getCompressionAlgorithm(input string) string {
|
||||
// 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"
|
||||
case ".tar.zstd":
|
||||
return "zstd"
|
||||
case ".tar.zst":
|
||||
return "zstd"
|
||||
case ".tar.lz4":
|
||||
return "lz4"
|
||||
case ".tar.xz":
|
||||
return "xz"
|
||||
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"
|
||||
default:
|
||||
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: */
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -11,6 +13,163 @@ import (
|
||||
"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.
|
||||
func TestNewServerSuccess(t *testing.T) {
|
||||
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.
|
||||
func TestNewServerInvalidConfig(t *testing.T) {
|
||||
C = Config{} // Reset global config.
|
||||
@@ -189,7 +388,7 @@ func TestHandlerArchiveStream(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
files, err := GetFilesFromDirectories(C.Directories)
|
||||
files, err := GetFilesFromDirectories(ctx, C.Directories)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get files", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -301,7 +500,7 @@ func TestCopyWithContextSuccess(t *testing.T) {
|
||||
src := strings.NewReader("hello world")
|
||||
dst := &strings.Builder{}
|
||||
|
||||
err := copyWithContext(ctx, dst, src)
|
||||
_, _, err := copyWithContext(ctx, dst, src)
|
||||
if err != nil {
|
||||
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.
|
||||
func TestCopyWithContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -325,17 +543,142 @@ func TestCopyWithContextCancellation(t *testing.T) {
|
||||
|
||||
// Start copy in goroutine
|
||||
errCh := make(chan error, 1)
|
||||
bytesCh := make(chan int64, 1)
|
||||
go func() {
|
||||
errCh <- copyWithContext(ctx, dst, src)
|
||||
n, _, err := copyWithContext(ctx, dst, src)
|
||||
bytesCh <- n
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
// Cancel context immediately
|
||||
cancel()
|
||||
|
||||
err := <-errCh
|
||||
<-bytesCh
|
||||
if err != context.Canceled {
|
||||
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: */
|
||||
|
||||
@@ -15,11 +15,23 @@ type (
|
||||
LogLevel string `json:"loglevel"`
|
||||
Directories []string `json:"directories"`
|
||||
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).
|
||||
ExcludePatterns []string `json:"exclude_patterns"` // Regex patterns to exclude from backup.
|
||||
FilenamePrefix string `json:"filename_prefix"` // Prefix for backup filename (default: "backup").
|
||||
CompressionAlgorithm string `json:"compression_algorithm"` // Compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz.
|
||||
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.
|
||||
}
|
||||
|
||||
// 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: */
|
||||
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"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.
|
||||
func TestCreateTarGzStreamBasic(t *testing.T) {
|
||||
// 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.
|
||||
func TestCreateTarGzStreamMultipleFiles(t *testing.T) {
|
||||
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.
|
||||
func TestCreateTarGzStreamCancellation(t *testing.T) {
|
||||
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.
|
||||
func TestCreateTarGzStreamNestedFiles(t *testing.T) {
|
||||
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) {
|
||||
// Use /dev/null as a test device file (character device).
|
||||
devNull := "/dev/null"
|
||||
@@ -315,8 +683,15 @@ func TestCreateTarGzStreamDeviceFile(t *testing.T) {
|
||||
if err != nil {
|
||||
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()
|
||||
@@ -331,23 +706,13 @@ func TestCreateTarGzStreamDeviceFile(t *testing.T) {
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Read the device entry
|
||||
header, err := tarReader.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar header: %v", err)
|
||||
}
|
||||
|
||||
if header.Typeflag != tar.TypeChar {
|
||||
t.Errorf("Expected character device type, got %c", 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)
|
||||
if header.Typeflag != expectedTypeflag {
|
||||
t.Errorf("Expected device type %c, got %c", expectedTypeflag, header.Typeflag)
|
||||
}
|
||||
|
||||
// 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: */
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
// 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 {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,25 +3,52 @@ package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var (
|
||||
err error
|
||||
loglevel slog.Level
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
@@ -49,6 +76,8 @@ func Init(level, filename string) error {
|
||||
loglevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
logLevel = loglevel
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
// Use the ReplaceAttr function on the handler options
|
||||
// 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...))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func Fatal(args ...any) {
|
||||
slog.Error(fmt.Sprint(args...))
|
||||
@@ -138,7 +207,11 @@ func Fatalf(format string, a ...any) {
|
||||
}
|
||||
|
||||
// Close closes log file descriptor.
|
||||
// Thread-safe: uses mutex to coordinate with Init and DebugLogger.
|
||||
func Close() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if Log == nil || Log == os.Stderr {
|
||||
return
|
||||
}
|
||||
@@ -147,6 +220,8 @@ func Close() {
|
||||
msg := fmt.Sprintln("Failed to close log file:", err)
|
||||
slog.Error(msg)
|
||||
}
|
||||
|
||||
Log = nil
|
||||
}
|
||||
|
||||
/* 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: */
|
||||
|
||||
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
|
||||
│ │ ├── archive.go # Common archive logic (createArchiveStream, writeFilesToTar)
|
||||
│ │ ├── gzip.go # CreateTarGzStream, CreateTarPgzipStream
|
||||
│ │ ├── bzip2.go # CreateTarBzip2Stream
|
||||
│ │ ├── zstd.go # CreateTarZstdStream
|
||||
│ │ ├── lz4.go # CreateTarLz4Stream
|
||||
│ │ ├── bzip2.go # CreateTarBzip2Stream, mapCompressionLevelToBzip2
|
||||
│ │ ├── zstd.go # CreateTarZstdStream, zstdLevel
|
||||
│ │ ├── lz4.go # CreateTarLz4Stream, mapCompressionLevelToLz4
|
||||
│ │ ├── xz.go # CreateTarXzStream
|
||||
│ │ ├── loadConfig.go # HJSON/JSON config parsing with validation
|
||||
│ │ ├── 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
|
||||
│ └── log/main.go # Logging wrapper around stdlib slog
|
||||
├── 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 |
|
||||
| port | 8086 | Listen port |
|
||||
| cert/key | - | TLS cert/key paths |
|
||||
| cert/key | - | TLS cert/key paths (required when nohttps=false) |
|
||||
| nohttps | false | Disable HTTPS |
|
||||
| location | /archive | API endpoint path |
|
||||
| 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) |
|
||||
| directories | - | Directories to backup (required) |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -85,35 +98,86 @@ json.Marshal(tmp)
|
||||
json.Unmarshal(buf, &C)
|
||||
```
|
||||
|
||||
### Stream Cancellation
|
||||
Context-aware write and copy functions:
|
||||
### Context-Aware I/O
|
||||
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
|
||||
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
|
||||
Uses `pipeErr` tracking to avoid noisy errors when reader closes early.
|
||||
Skips files that can't be read (logs warning, continues backup).
|
||||
- `isPipeClosedError()` checks for "io: read/write on closed pipe" errors
|
||||
|
||||
### Content-Disposition Filename
|
||||
Backup filenames include timestamp and configured prefix: `backup-20060102-150405.tar.gz` (extension depends on compression_algorithm).
|
||||
|
||||
### Special File Types
|
||||
- **Device files** (character/block) are included with correct major/minor numbers.
|
||||
- **Sockets** are skipped (no entry created).
|
||||
- **Symlinks** are stored as symlinks (target path stored).
|
||||
- **Named pipes** and other special files are included (tar header created).
|
||||
- **Regular files** are compressed; other file types have no data section.
|
||||
- **Regular files**: streamed into tar archive with content
|
||||
- **Directories**: included as tar directory entries
|
||||
- **Symlinks**: stored as symlink entries (target path preserved) - uses os.Lstat, no cycles possible
|
||||
- **Broken symlinks**: archived with their target path (even if target doesn't exist)
|
||||
- **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
|
||||
- `writeWithContext` checks context before calling fn.
|
||||
- `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
|
||||
- Exclude patterns are precompiled after config load for faster matching.
|
||||
- Compression level is configurable (1-9); default 9 uses more CPU.
|
||||
- Large directory walks use `filepath.WalkDir` (efficient).
|
||||
- Context cancellation checked on each file during directory scan.
|
||||
|
||||
## Coding Style & Conventions
|
||||
|
||||
@@ -128,7 +192,8 @@ GetFilesFromDirectories()
|
||||
// Unexported (private): verbNoun or isNoun pattern
|
||||
writeWithContext()
|
||||
copyWithContext()
|
||||
isPipeClosedError() // predicate helper
|
||||
isPipeClosedError()
|
||||
isExcluded()
|
||||
defaultConfigPath()
|
||||
```
|
||||
|
||||
@@ -137,6 +202,7 @@ Struct fields use PascalCase, json tags use snake_case:
|
||||
```go
|
||||
BackupTimeout int `json:"backup_timeout"`
|
||||
CompressionLevel int `json:"compression_level"`
|
||||
DirScanTimeout int `json:"dir_scan_timeout"`
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
@@ -149,6 +215,7 @@ CompressionLevel int `json:"compression_level"`
|
||||
- Past tense: `"Failed to close gzip writer: %v", err`
|
||||
- Context included: `"Skipping %s: %v", filename, err`
|
||||
- Appropriate level: Warn for recoverable, Error for issues, Fatal for startup failures
|
||||
- Log levels: error, warn, info, debug
|
||||
|
||||
### Config Validation Pattern
|
||||
For each config option: set default first, then immediately validate:
|
||||
@@ -166,7 +233,7 @@ if C.Port < 1 || C.Port > 65535 {
|
||||
|
||||
### Go Formatting
|
||||
- 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)
|
||||
| Rule | Reason |
|
||||
@@ -181,24 +248,38 @@ if C.Port < 1 || C.Port > 65535 {
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
go test ./... # Run tests
|
||||
go test ./... # Run tests (runs make clean first)
|
||||
go test -v -run TestName ./... # Run specific test
|
||||
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
|
||||
- Reset global config: `C = Config{}`
|
||||
- Reset exclude patterns: `excludePatterns = nil`
|
||||
- Use `t.TempDir()` for temporary files
|
||||
- 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
|
||||
```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 clean # Remove binary
|
||||
make test # Run tests
|
||||
make clean # Remove binary, test_data/tmp, test_data/test1
|
||||
make test # Clean + test (deterministic)
|
||||
make upgrade # Update dependencies
|
||||
make help # Show targets
|
||||
```
|
||||
@@ -208,3 +289,20 @@ Version is injected at build time via ldflags:
|
||||
```go
|
||||
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. */
|
||||
"port": 8086,
|
||||
|
||||
/* Path location of certificate file */
|
||||
/* Path location of test certificate file */
|
||||
"cert": "../../test_data/example.crt",
|
||||
|
||||
/* Path location of certificate key */
|
||||
/* Path location of test certificate key */
|
||||
"key": "../../test_data/example.key",
|
||||
|
||||
/* We really want to use https for backup archive tranferring. */
|
||||
@@ -24,7 +24,7 @@
|
||||
"password": "SharpShooter",
|
||||
|
||||
/* Where to store logs. */
|
||||
"log": "/var/log/backer.log",
|
||||
"log": "../../test_data/tmp/backer.log",
|
||||
|
||||
/* Verbosity of logs */
|
||||
"loglevel": "info",
|
||||
@@ -39,5 +39,5 @@
|
||||
"backup_timeout": 60,
|
||||
|
||||
/* Gzip compression level (1-9, where 9 is best compression). */
|
||||
"compression_level": 6
|
||||
"compression_level": 3
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user