refactor: add AppError helpers and harden upload/download error handling
- refactor: replace inline AppError literals with model.New*Error constructors in service and handler layers - feat: PromoteStaged falls back to copy/delete when os.Rename returns EXDEV (cross-device link) - fix: upload error messages no longer leak internal multipart field names or parser implementation details - fix: upload size violations are converted to domain error ErrUploadTooLarge at the handler boundary - fix: download logs error and size mismatch when io.Copy fails or writes fewer bytes than expected - test: add tests for field name leak prevention, parser error sanitization, read errors after response start, and EXDEV fallback
This commit is contained in:
@@ -2,11 +2,13 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,6 +16,8 @@ const (
|
||||
stagingPathPrefix = "staging"
|
||||
)
|
||||
|
||||
var renameLocalFile = os.Rename
|
||||
|
||||
// LocalStorage implements StorageBackend using the local filesystem.
|
||||
type LocalStorage struct {
|
||||
basePath string
|
||||
@@ -87,13 +91,47 @@ func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath st
|
||||
return fmt.Errorf("create parent directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(fullStagedPath, fullFinalPath); err != nil {
|
||||
if err := renameLocalFile(fullStagedPath, fullFinalPath); err != nil {
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
return promoteByCopy(fullStagedPath, fullFinalPath)
|
||||
}
|
||||
return fmt.Errorf("promote staged file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promoteByCopy(stagedPath, finalPath string) error {
|
||||
source, err := os.Open(stagedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open staged file: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create final file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(destination, source); err != nil {
|
||||
destination.Close()
|
||||
os.Remove(finalPath)
|
||||
return fmt.Errorf("copy staged file: %w", err)
|
||||
}
|
||||
|
||||
if err := destination.Close(); err != nil {
|
||||
os.Remove(finalPath)
|
||||
return fmt.Errorf("close final file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(stagedPath); err != nil {
|
||||
os.Remove(finalPath)
|
||||
return fmt.Errorf("delete staged file after copy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open returns a reader for the file at path under the storage root.
|
||||
func (s *LocalStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -179,6 +180,50 @@ func TestPromoteRequiresDataFinalPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteStagedFallsBackToCopyOnEXDEV(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalStorage(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalStorage: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
content := "copy across filesystems"
|
||||
_, err = store.SaveStaged(ctx, "staging/cross-device.txt", strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveStaged: %v", err)
|
||||
}
|
||||
|
||||
originalRename := renameLocalFile
|
||||
renameLocalFile = func(_, _ string) error {
|
||||
return &os.LinkError{Op: "rename", Err: syscall.EXDEV}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
renameLocalFile = originalRename
|
||||
})
|
||||
|
||||
if err := store.PromoteStaged(ctx, "staging/cross-device.txt", "data/cross-device.txt"); err != nil {
|
||||
t.Fatalf("PromoteStaged: %v", err)
|
||||
}
|
||||
|
||||
reader, err := store.Open(ctx, "data/cross-device.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open promoted file: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("content = %q, want %q", string(got), content)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dir + "/staging/cross-device.txt"); !os.IsNotExist(err) {
|
||||
t.Errorf("staged file should be removed after fallback promote, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewLocalStorage(dir)
|
||||
|
||||
Reference in New Issue
Block a user