Implement FilesystemStore satisfying the FileStore interface for local filesystem storage, replacing MinIO for simpler deployments. - Atomic writes via temp file + os.Rename (no partial files) - SHA-256 checksum computed on Put via io.MultiWriter - Get/GetVersion return os.File (GetVersion ignores versionID) - Delete is idempotent (no error if file missing) - Copy uses same atomic write pattern - PresignPut returns ErrPresignNotSupported - Ping verifies root directory is writable - Wire NewFilesystemStore in main.go backend switch - 14 unit tests covering all methods including atomicity Closes #127
178 lines
4.8 KiB
Go
178 lines
4.8 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// ErrPresignNotSupported is returned when presigned URLs are requested from a
|
|
// backend that does not support them.
|
|
var ErrPresignNotSupported = errors.New("presigned URLs not supported by filesystem backend")
|
|
|
|
// Compile-time check: *FilesystemStore implements FileStore.
|
|
var _ FileStore = (*FilesystemStore)(nil)
|
|
|
|
// FilesystemStore stores objects as files under a root directory.
|
|
type FilesystemStore struct {
|
|
root string // absolute path
|
|
}
|
|
|
|
// NewFilesystemStore creates a new filesystem-backed store rooted at root.
|
|
// The directory is created if it does not exist.
|
|
func NewFilesystemStore(root string) (*FilesystemStore, error) {
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving root path: %w", err)
|
|
}
|
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
return nil, fmt.Errorf("creating root directory: %w", err)
|
|
}
|
|
return &FilesystemStore{root: abs}, nil
|
|
}
|
|
|
|
// path returns the absolute filesystem path for a storage key.
|
|
func (fs *FilesystemStore) path(key string) string {
|
|
return filepath.Join(fs.root, filepath.FromSlash(key))
|
|
}
|
|
|
|
// Put writes reader to the file at key using atomic rename.
|
|
// SHA-256 checksum is computed during write and returned in PutResult.
|
|
func (fs *FilesystemStore) Put(_ context.Context, key string, reader io.Reader, _ int64, _ string) (*PutResult, error) {
|
|
dest := fs.path(key)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return nil, fmt.Errorf("creating directories: %w", err)
|
|
}
|
|
|
|
// Write to a temp file in the same directory so os.Rename is atomic.
|
|
tmp, err := os.CreateTemp(filepath.Dir(dest), ".silo-tmp-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer func() {
|
|
// Clean up temp file on any failure path.
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
}()
|
|
|
|
h := sha256.New()
|
|
w := io.MultiWriter(tmp, h)
|
|
|
|
n, err := io.Copy(w, reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("writing file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return nil, fmt.Errorf("closing temp file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, dest); err != nil {
|
|
return nil, fmt.Errorf("renaming temp file: %w", err)
|
|
}
|
|
|
|
return &PutResult{
|
|
Key: key,
|
|
Size: n,
|
|
Checksum: hex.EncodeToString(h.Sum(nil)),
|
|
}, nil
|
|
}
|
|
|
|
// Get opens the file at key for reading.
|
|
func (fs *FilesystemStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
|
f, err := os.Open(fs.path(key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening file: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// GetVersion delegates to Get — filesystem storage has no versioning.
|
|
func (fs *FilesystemStore) GetVersion(ctx context.Context, key string, _ string) (io.ReadCloser, error) {
|
|
return fs.Get(ctx, key)
|
|
}
|
|
|
|
// Delete removes the file at key. No error if already absent.
|
|
func (fs *FilesystemStore) Delete(_ context.Context, key string) error {
|
|
err := os.Remove(fs.path(key))
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("removing file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Exists reports whether the file at key exists.
|
|
func (fs *FilesystemStore) Exists(_ context.Context, key string) (bool, error) {
|
|
_, err := os.Stat(fs.path(key))
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("checking file: %w", err)
|
|
}
|
|
|
|
// Copy duplicates a file from srcKey to dstKey using atomic rename.
|
|
func (fs *FilesystemStore) Copy(_ context.Context, srcKey, dstKey string) error {
|
|
srcPath := fs.path(srcKey)
|
|
dstPath := fs.path(dstKey)
|
|
|
|
src, err := os.Open(srcPath)
|
|
if err != nil {
|
|
return fmt.Errorf("opening source: %w", err)
|
|
}
|
|
defer src.Close()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
|
return fmt.Errorf("creating directories: %w", err)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(filepath.Dir(dstPath), ".silo-tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("creating temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer func() {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
}()
|
|
|
|
if _, err := io.Copy(tmp, src); err != nil {
|
|
return fmt.Errorf("copying file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("closing temp file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, dstPath); err != nil {
|
|
return fmt.Errorf("renaming temp file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PresignPut is not supported by the filesystem backend.
|
|
func (fs *FilesystemStore) PresignPut(_ context.Context, _ string, _ time.Duration) (*url.URL, error) {
|
|
return nil, ErrPresignNotSupported
|
|
}
|
|
|
|
// Ping verifies the root directory is accessible and writable.
|
|
func (fs *FilesystemStore) Ping(_ context.Context) error {
|
|
tmp, err := os.CreateTemp(fs.root, ".silo-ping-*")
|
|
if err != nil {
|
|
return fmt.Errorf("storage ping failed: %w", err)
|
|
}
|
|
name := tmp.Name()
|
|
tmp.Close()
|
|
os.Remove(name)
|
|
return nil
|
|
}
|