diff --git a/internal/backer/archive.go b/internal/backer/archive.go index 573fccd..75aac24 100644 --- a/internal/backer/archive.go +++ b/internal/backer/archive.go @@ -173,7 +173,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) @@ -188,7 +188,7 @@ func writeFilesToTar(ctx context.Context, filepaths []string, tw *tar.Writer, cl } } -// getInode extracts the inode number from os.FileInfo. +// 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 { diff --git a/internal/backer/newServer.go b/internal/backer/newServer.go index 481094c..3c2070c 100644 --- a/internal/backer/newServer.go +++ b/internal/backer/newServer.go @@ -2,8 +2,10 @@ package backer import ( "context" + "crypto/md5" "crypto/subtle" "crypto/tls" + "encoding/hex" "errors" "fmt" "io" @@ -71,7 +73,7 @@ func NewServer(configPath string) (*ServerWrapper, error) { mux := http.NewServeMux() - mux.HandleFunc(C.Location, func(w http.ResponseWriter, r *http.Request) { + backupHandler := func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() // No auth? Good bye. @@ -105,7 +107,6 @@ func NewServer(configPath string) (*ServerWrapper, error) { if err != nil { log.Errorf("Failed to get files: %v", err) - w.Header().Set("Server", "backer") http.Error(w, "Failed to get files", http.StatusInternalServerError) return @@ -121,7 +122,7 @@ func NewServer(configPath string) (*ServerWrapper, error) { algorithm string ) - requestedExt := strings.TrimPrefix(path.Ext(r.URL.Path), ".") + requestedExt := r.URL.Path algorithm = getCompressionAlgorithm(requestedExt) @@ -132,7 +133,11 @@ func NewServer(configPath string) (*ServerWrapper, error) { 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) @@ -157,23 +162,43 @@ func NewServer(configPath string) (*ServerWrapper, 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) - }) + log.Infof("Backup completed: client=%s user=%s files=%d bytes=%d md5=%s duration=%s", clientIP, username, len(files), bytesWritten, md5Hash, time.Since(backupStart)) + } + + mux.Handle(C.Location, http.HandlerFunc(backupHandler)) + + extensions := []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tar.lz4", ".tar.zst", ".tar.zstd"} + for _, ext := range extensions { + muxPath := C.Location + ext + mux.Handle(muxPath, http.HandlerFunc(backupHandler)) + } // HTTP mode if nohttps is enabled in config. // HTTPS mode if nohttps is disabled in config. @@ -226,15 +251,20 @@ 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: } @@ -242,21 +272,29 @@ func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error { if n > 0 { select { case <-ctx.Done(): - return ctx.Err() + return bytesWritten, "", ctx.Err() default: } - if _, werr := dst.Write(buf[:n]); werr != nil { - return werr + 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 } } } @@ -279,17 +317,39 @@ func getClientIP(r *http.Request) string { // 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. -func getCompressionAlgorithm(requestedExt string) string { - switch requestedExt { - case "tar.bz2": +// 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.zst": + case ".tar.zstd": return "zstd" - case "tar.lz4": + case ".tar.zst": + return "zstd" + case ".tar.lz4": return "lz4" - case "tar.xz": + case ".tar.xz": return "xz" - case "tar.gz": + case ".tar.gz": + return "pgzip" + case ".xz": + return "xz" + case ".bz2": + return "bzip2" + case ".zst": + return "zstd" + case ".zstd": + return "zstd" + case ".lz4": + return "lz4" + case ".gz": return "pgzip" default: return C.DefaultCompression diff --git a/internal/backer/newServer_test.go b/internal/backer/newServer_test.go index 85a65c1..6bbd3a5 100644 --- a/internal/backer/newServer_test.go +++ b/internal/backer/newServer_test.go @@ -463,7 +463,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) } @@ -473,6 +473,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()) @@ -487,14 +506,18 @@ 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) }