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)) }