package storage import ( "context" "fmt" "io" "os" "path/filepath" "strings" ) const ( dataPathPrefix = "data" stagingPathPrefix = "staging" ) // 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 := os.Rename(fullStagedPath, fullFinalPath); err != nil { return fmt.Errorf("promote staged file: %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)) }