Files
go-storage/local.go

349 lines
8.1 KiB
Go

package go_storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// LocalStorage implements the Storage interface using the local filesystem.
// It is safe for concurrent use as it relies on filesystem operations which
// are handled by the OS.
type LocalStorage struct {
basePath string
}
// NewLocalStorage creates a new LocalStorage instance with the given base directory.
// The base directory will be created if it doesn't exist.
func NewLocalStorage(basePath string) (*LocalStorage, error) {
absPath, err := filepath.Abs(basePath)
if err != nil {
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
if err := os.MkdirAll(absPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create base directory: %w", err)
}
return &LocalStorage{
basePath: absPath,
}, nil
}
// resolvePath safely resolves a remote path to an absolute local path,
// ensuring it stays within the base directory.
func (s *LocalStorage) resolvePath(remotePath string) (string, error) {
// Clean the path to remove any .. or . components
cleanPath := filepath.Clean(remotePath)
// Remove leading slashes to make it relative
cleanPath = strings.TrimPrefix(cleanPath, string(filepath.Separator))
// Join with base path
fullPath := filepath.Join(s.basePath, cleanPath)
// Verify the resolved path is still within basePath
if !strings.HasPrefix(fullPath, s.basePath) {
return "", fmt.Errorf("path traversal attempt detected: %s", remotePath)
}
return fullPath, nil
}
// Upload uploads a local file to the storage with the given target name.
func (s *LocalStorage) Upload(ctx context.Context, localPath string, remotePath string) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
targetPath, err := s.resolvePath(remotePath)
if err != nil {
return err
}
// Open source file
srcFile, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer srcFile.Close()
// Create parent directories if needed
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err)
}
// Create destination file
dstFile, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer dstFile.Close()
// Copy with context cancellation checks
buf := make([]byte, 32*1024) // 32KB buffer
for {
select {
case <-ctx.Done():
// Clean up partial file on cancellation
os.Remove(targetPath)
return ctx.Err()
default:
}
n, readErr := srcFile.Read(buf)
if n > 0 {
if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil {
os.Remove(targetPath)
return fmt.Errorf("failed to write to destination: %w", writeErr)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
os.Remove(targetPath)
return fmt.Errorf("failed to read source file: %w", readErr)
}
}
return nil
}
// Download downloads a file from storage to a local path.
func (s *LocalStorage) Download(ctx context.Context, remotePath string, localPath string) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
sourcePath, err := s.resolvePath(remotePath)
if err != nil {
return err
}
// Open source file
srcFile, err := os.Open(sourcePath)
if err != nil {
return fmt.Errorf("failed to open remote file: %w", err)
}
defer srcFile.Close()
// Create parent directories for local path if needed
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return fmt.Errorf("failed to create local parent directories: %w", err)
}
// Create destination file
dstFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer dstFile.Close()
// Copy with context cancellation checks
buf := make([]byte, 32*1024) // 32KB buffer
for {
select {
case <-ctx.Done():
// Clean up partial file on cancellation
os.Remove(localPath)
return ctx.Err()
default:
}
n, readErr := srcFile.Read(buf)
if n > 0 {
if _, writeErr := dstFile.Write(buf[:n]); writeErr != nil {
os.Remove(localPath)
return fmt.Errorf("failed to write to local file: %w", writeErr)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
os.Remove(localPath)
return fmt.Errorf("failed to read remote file: %w", readErr)
}
}
return nil
}
// List returns all items in the storage that match the given prefix.
func (s *LocalStorage) List(ctx context.Context, prefix string) ([]Item, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
var items []Item
// Clean and resolve the prefix path
searchPath := s.basePath
cleanPrefix := strings.TrimPrefix(filepath.Clean(prefix), string(filepath.Separator))
if cleanPrefix != "" && cleanPrefix != "." {
searchPath = filepath.Join(s.basePath, cleanPrefix)
}
// Check if the search path exists
info, err := os.Stat(searchPath)
if os.IsNotExist(err) {
// If the exact path doesn't exist, try to find files with this prefix
// by walking the parent directory
parentDir := filepath.Dir(searchPath)
baseName := filepath.Base(searchPath)
if _, err := os.Stat(parentDir); os.IsNotExist(err) {
return items, nil // Return empty list if parent doesn't exist
}
entries, err := os.ReadDir(parentDir)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
for _, entry := range entries {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if strings.HasPrefix(entry.Name(), baseName) {
fullPath := filepath.Join(parentDir, entry.Name())
item, err := s.fileInfoToItem(fullPath)
if err != nil {
continue // Skip files we can't stat
}
items = append(items, item)
}
}
return items, nil
}
if err != nil {
return nil, fmt.Errorf("failed to stat path: %w", err)
}
// If it's a file, return just that file
if !info.IsDir() {
item, err := s.fileInfoToItem(searchPath)
if err != nil {
return nil, err
}
return []Item{item}, nil
}
// Walk the directory
err = filepath.WalkDir(searchPath, func(path string, d os.DirEntry, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err != nil {
return nil // Skip entries with errors
}
item, err := s.fileInfoToItem(path)
if err != nil {
return nil // Skip files we can't stat
}
items = append(items, item)
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return items, nil
}
// fileInfoToItem converts a file path to an Item.
func (s *LocalStorage) fileInfoToItem(path string) (Item, error) {
info, err := os.Stat(path)
if err != nil {
return Item{}, err
}
// Get relative path from base
relPath, err := filepath.Rel(s.basePath, path)
if err != nil {
relPath = path
}
// Normalize to forward slashes for consistency
relPath = filepath.ToSlash(relPath)
size := info.Size()
if info.IsDir() {
size = 0
}
return Item{
Key: relPath,
ModifiedTime: info.ModTime(),
Size: size,
IsDirectory: info.IsDir(),
}, nil
}
// Remove deletes a file from storage.
func (s *LocalStorage) Remove(ctx context.Context, remotePath string) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
targetPath, err := s.resolvePath(remotePath)
if err != nil {
return err
}
// Check if file exists
info, err := os.Stat(targetPath)
if os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", remotePath)
}
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
// Remove file or directory
if info.IsDir() {
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("failed to remove directory: %w", err)
}
} else {
if err := os.Remove(targetPath); err != nil {
return fmt.Errorf("failed to remove file: %w", err)
}
}
return nil
}
// Close releases any resources held by the storage implementation.
// For LocalStorage, this is a no-op as there are no persistent resources.
func (s *LocalStorage) Close() error {
return nil
}
// BasePath returns the base directory path of the storage.
func (s *LocalStorage) BasePath() string {
return s.basePath
}