feat(storage): LocalFS implementation with sha256 + path safety

This commit is contained in:
2026-05-04 00:22:38 +08:00
parent eb73fbba46
commit 33838f1820
3 changed files with 192 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// LocalFS 把对象写到本地文件系统的 base 之下。
type LocalFS struct {
base string
}
// NewLocalFS 构造时校验 base 是绝对路径且存在(不存在则尝试创建)。
func NewLocalFS(base string) (*LocalFS, error) {
if !filepath.IsAbs(base) {
return nil, fmt.Errorf("storage: base must be absolute, got %q", base)
}
if err := os.MkdirAll(base, 0o755); err != nil {
return nil, fmt.Errorf("storage: mkdir base: %w", err)
}
return &LocalFS{base: filepath.Clean(base)}, nil
}
// resolveSafe 把 key 限制在 base 之下,拒绝 ".." / 绝对路径。
func (l *LocalFS) resolveSafe(key string) (string, error) {
// Reject absolute paths
if filepath.IsAbs(key) {
return "", errors.New("storage: key must be relative")
}
// Check for .. components which could escape
parts := strings.FieldsFunc(key, func(r rune) bool { return r == '/' || r == '\\' })
for _, part := range parts {
if part == ".." {
return "", errors.New("storage: key contains ..")
}
}
// Safely join and clean
full := filepath.Join(l.base, key)
full = filepath.Clean(full)
// Verify it's under base by checking relative path doesn't escape
rel, err := filepath.Rel(l.base, full)
if err != nil || strings.HasPrefix(rel, "..") {
return "", errors.New("storage: key escapes base")
}
return full, nil
}
func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, mimeType string) (PutResult, error) {
full, err := l.resolveSafe(key)
if err != nil {
return PutResult{}, err
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return PutResult{}, fmt.Errorf("storage: mkdir: %w", err)
}
tmp := full + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return PutResult{}, err
}
h := sha256.New()
written, err := io.Copy(io.MultiWriter(f, h), r)
cerr := f.Close()
if err != nil {
_ = os.Remove(tmp)
return PutResult{}, err
}
if cerr != nil {
_ = os.Remove(tmp)
return PutResult{}, cerr
}
if err := os.Rename(tmp, full); err != nil {
_ = os.Remove(tmp)
return PutResult{}, err
}
return PutResult{
StoragePath: filepath.ToSlash(strings.TrimPrefix(full, l.base+string(os.PathSeparator))),
SHA256: hex.EncodeToString(h.Sum(nil)),
Size: written,
}, nil
}
func (l *LocalFS) Get(ctx context.Context, storagePath string) (io.ReadCloser, error) {
full, err := l.resolveSafe(storagePath)
if err != nil {
return nil, err
}
return os.Open(full)
}
func (l *LocalFS) Delete(ctx context.Context, storagePath string) error {
full, err := l.resolveSafe(storagePath)
if err != nil {
return err
}
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}