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 }