28e17a5b08
- 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
201 lines
5.7 KiB
Go
201 lines
5.7 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
const (
|
|
dataPathPrefix = "data"
|
|
stagingPathPrefix = "staging"
|
|
)
|
|
|
|
var renameLocalFile = os.Rename
|
|
|
|
// LocalStorage implements StorageBackend using the local filesystem.
|
|
type LocalStorage struct {
|
|
basePath string
|
|
}
|
|
|
|
// NewLocalStorage creates a LocalStorage rooted at basePath. The path is
|
|
// resolved to an absolute path and verified to exist.
|
|
func NewLocalStorage(basePath string) (*LocalStorage, error) {
|
|
abs, err := filepath.Abs(basePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve storage path: %w", err)
|
|
}
|
|
return &LocalStorage{basePath: abs}, nil
|
|
}
|
|
|
|
// SaveStaged writes the contents of reader to a staging path under the storage root.
|
|
func (s *LocalStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
|
fullPath := filepath.Join(s.basePath, path)
|
|
if !isSubPath(s.basePath, fullPath) {
|
|
return 0, fmt.Errorf("storage: path traversal attempt: %s", path)
|
|
}
|
|
if !hasPathPrefix(path, stagingPathPrefix) {
|
|
return 0, fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
|
|
return 0, fmt.Errorf("create parent directory: %w", err)
|
|
}
|
|
|
|
file, err := os.Create(fullPath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
written, err := io.Copy(file, reader)
|
|
if err != nil {
|
|
// Best-effort cleanup on write failure.
|
|
os.Remove(fullPath)
|
|
return 0, fmt.Errorf("write file: %w", err)
|
|
}
|
|
|
|
return written, nil
|
|
}
|
|
|
|
// PromoteStaged moves a staged file to its final path under the storage root.
|
|
func (s *LocalStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
|
fullStagedPath := filepath.Join(s.basePath, stagedPath)
|
|
if !isSubPath(s.basePath, fullStagedPath) {
|
|
return fmt.Errorf("storage: path traversal attempt: %s", stagedPath)
|
|
}
|
|
if !hasPathPrefix(stagedPath, stagingPathPrefix) {
|
|
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, stagedPath)
|
|
}
|
|
|
|
fullFinalPath := filepath.Join(s.basePath, finalPath)
|
|
if !isSubPath(s.basePath, fullFinalPath) {
|
|
return fmt.Errorf("storage: path traversal attempt: %s", finalPath)
|
|
}
|
|
if !hasPathPrefix(finalPath, dataPathPrefix) {
|
|
return fmt.Errorf("storage: final path must be under %s/: %s", dataPathPrefix, finalPath)
|
|
}
|
|
|
|
if _, err := os.Stat(fullFinalPath); err == nil {
|
|
return fmt.Errorf("final file already exists: %s", finalPath)
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("check final file: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(fullFinalPath), 0750); err != nil {
|
|
return fmt.Errorf("create parent directory: %w", err)
|
|
}
|
|
|
|
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)
|
|
if !isSubPath(s.basePath, fullPath) {
|
|
return nil, fmt.Errorf("storage: path traversal attempt: %s", path)
|
|
}
|
|
|
|
file, err := os.Open(fullPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("file not found on disk: %s", path)
|
|
}
|
|
return nil, fmt.Errorf("open file: %w", err)
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
// DeleteStaged removes a staged file under the storage root.
|
|
func (s *LocalStorage) DeleteStaged(_ context.Context, path string) error {
|
|
fullPath := filepath.Join(s.basePath, path)
|
|
if !isSubPath(s.basePath, fullPath) {
|
|
return fmt.Errorf("storage: path traversal attempt: %s", path)
|
|
}
|
|
if !hasPathPrefix(path, stagingPathPrefix) {
|
|
return fmt.Errorf("storage: staged path must be under %s/: %s", stagingPathPrefix, path)
|
|
}
|
|
|
|
err := os.Remove(fullPath)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Delete removes the file at path under the storage root.
|
|
func (s *LocalStorage) Delete(_ context.Context, path string) error {
|
|
fullPath := filepath.Join(s.basePath, path)
|
|
if !isSubPath(s.basePath, fullPath) {
|
|
return fmt.Errorf("storage: path traversal attempt: %s", path)
|
|
}
|
|
|
|
err := os.Remove(fullPath)
|
|
if os.IsNotExist(err) {
|
|
return nil // idempotent: already gone
|
|
}
|
|
return err
|
|
}
|
|
|
|
// isSubPath returns true if target is within base (no path traversal).
|
|
func isSubPath(base, target string) bool {
|
|
rel, err := filepath.Rel(base, target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(rel) {
|
|
return false
|
|
}
|
|
// Reject paths that escape the base directory.
|
|
// filepath.Rel returns ".." or starts with "../" for paths outside base.
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func hasPathPrefix(path, prefix string) bool {
|
|
clean := filepath.Clean(path)
|
|
return clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator))
|
|
}
|