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 }