You've already forked agentic-coding-workflow
feat(storage): LocalFS implementation with sha256 + path safety
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalFS_PutGetDelete_Roundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fs, err := NewLocalFS(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res, err := fs.Put(context.Background(), "2026/05/abc.txt",
|
||||||
|
bytes.NewReader([]byte("hello")), "text/plain")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2026/05/abc.txt", res.StoragePath)
|
||||||
|
require.Equal(t, int64(5), res.Size)
|
||||||
|
require.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", res.SHA256)
|
||||||
|
|
||||||
|
r, err := fs.Get(context.Background(), res.StoragePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
r.Close()
|
||||||
|
require.Equal(t, "hello", string(got))
|
||||||
|
|
||||||
|
full := filepath.Join(dir, res.StoragePath)
|
||||||
|
_, err = os.Stat(full)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, fs.Delete(context.Background(), res.StoragePath))
|
||||||
|
_, err = os.Stat(full)
|
||||||
|
require.True(t, os.IsNotExist(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalFS_RejectPathTraversal(t *testing.T) {
|
||||||
|
fs, _ := NewLocalFS(t.TempDir())
|
||||||
|
_, err := fs.Put(context.Background(), "../escape.txt", bytes.NewReader(nil), "text/plain")
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = fs.Get(context.Background(), "../etc/passwd")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Error(t, fs.Delete(context.Background(), "../oops"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalFS_DeleteNonexistentIsNoop(t *testing.T) {
|
||||||
|
fs, _ := NewLocalFS(t.TempDir())
|
||||||
|
require.NoError(t, fs.Delete(context.Background(), "nope/nope.txt"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package storage 抽象附件二进制存储。MVP 默认 LocalFS;后续可加 S3。
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PutResult 由 Put 返回,便于调用方一次拿到 path + sha256 + 实际写入字节数。
|
||||||
|
type PutResult struct {
|
||||||
|
StoragePath string
|
||||||
|
SHA256 string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage 是后端无关的存储接口。
|
||||||
|
type Storage interface {
|
||||||
|
// Put 将 r 流式写入存储,返回相对 path、sha256 和实际字节数。
|
||||||
|
// key 由调用方指定(例如 "yyyy/mm/<uuid>.<ext>");实现层校验并放入 base 之下。
|
||||||
|
Put(ctx context.Context, key string, r io.Reader, mimeType string) (PutResult, error)
|
||||||
|
|
||||||
|
// Get 返回 storagePath 对应的 ReadCloser。调用方负责关闭。
|
||||||
|
Get(ctx context.Context, storagePath string) (io.ReadCloser, error)
|
||||||
|
|
||||||
|
// Delete 删除文件。文件不存在视为成功(幂等)。
|
||||||
|
Delete(ctx context.Context, storagePath string) error
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user