package storage import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "mime" "os" "path/filepath" "strings" "sync/atomic" ) // 每次读写使用的缓冲大小:256KB,对齐参考实现 SystemFileStorage.chunk_size。 const localChunkSize = 256 * 1024 // LocalStorage 本地文件系统引擎。 // // 相比参考实现(SystemFileStorage)的改进: // - 双重路径防护:清洗相对路径 + 根目录前缀校验 + 符号链接逃逸校验; // - 全部落盘走「临时文件 + fsync + 原子重命名」,断电/中断不产生半截文件; // - 下载使用 io.NewSectionReader 支持任意 Range,无需整文件读入内存。 type LocalStorage struct { // root 存储根目录(绝对路径)。 root string // rootReal 经符号链接解析后的真实根目录,用于逃逸校验。 rootReal string } // NewLocalStorage 构造本地引擎。root 为空时使用系统临时目录下的 fileshare_storage。 func NewLocalStorage(root string) (*LocalStorage, error) { if strings.TrimSpace(root) == "" { root = filepath.Join(os.TempDir(), "fileshare_storage") } abs, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("storage/local: 解析根目录失败: %w", err) } if err := os.MkdirAll(abs, 0o755); err != nil { return nil, fmt.Errorf("storage/local: 创建根目录失败: %w", err) } real := abs if resolved, err := filepath.EvalSymlinks(abs); err == nil { real = resolved } return &LocalStorage{root: abs, rootReal: real}, nil } func init() { RegisterEngine("local", func(ctx context.Context) (Storage, error) { return NewLocalStorage(engineOptions.Local.Root) }) } // withinRoot 判断路径 p 是否位于 root 内(含 root 本身)。 func withinRoot(p, root string) bool { p = filepath.Clean(p) root = filepath.Clean(root) if p == root { return true } return strings.HasPrefix(p, root+string(os.PathSeparator)) } // absPath 将存储侧相对路径解析为根目录内的绝对路径。 // 任何路径穿越或符号链接逃逸都会返回 ErrInvalidPath。 func (l *LocalStorage) absPath(savePath string) (string, error) { cleaned, ok := SanitizePath(savePath) if !ok { return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath) } full := filepath.Join(l.root, filepath.FromSlash(cleaned)) if !withinRoot(full, l.root) { return "", fmt.Errorf("%w: %q", ErrInvalidPath, savePath) } // 符号链接逃逸校验:文件已存在时解析真实路径;不存在时校验最深已存在的父目录。 if real, err := filepath.EvalSymlinks(full); err == nil { if !withinRoot(real, l.rootReal) { return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath) } } else { dir := filepath.Dir(full) if realDir, err := filepath.EvalSymlinks(dir); err == nil && !withinRoot(realDir, l.rootReal) { return "", fmt.Errorf("%w: 符号链接逃逸 %q", ErrInvalidPath, savePath) } } return full, nil } // SaveFile 流式保存:256KB 分块读取写入临时文件,fsync 后原子重命名。 func (l *LocalStorage) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) { full, err := l.absPath(savePath) if err != nil { return 0, err } src := &countingReader{r: r} if err := writeFileAtomic(full, src); err != nil { return src.count(), err } return src.count(), nil } // DeleteFile 删除文件;文件不存在时静默成功(对齐契约)。 func (l *LocalStorage) DeleteFile(ctx context.Context, savePath string) error { full, err := l.absPath(savePath) if err != nil { return err } if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("storage/local: 删除失败: %w", err) } return nil } // Open 打开文件下载流;rng 非 nil 时用 SectionReader 实现 Range 语义。 func (l *LocalStorage) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) { full, err := l.absPath(savePath) if err != nil { return nil, err } f, err := os.Open(full) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return nil, fmt.Errorf("storage/local: 打开文件失败: %w", err) } info, err := f.Stat() if err != nil { _ = f.Close() return nil, fmt.Errorf("storage/local: 获取文件信息失败: %w", err) } if info.IsDir() { _ = f.Close() return nil, ErrNotFound } size := info.Size() start, end := int64(0), size-1 if rng != nil { if rng.Start < 0 || (rng.End != -1 && rng.End < rng.Start) { _ = f.Close() return nil, fmt.Errorf("%w: 非法 Range", ErrRangeNotSatisfiable) } if rng.Start >= size { _ = f.Close() return nil, ErrRangeNotSatisfiable } start = rng.Start end = size - 1 if rng.End != -1 && rng.End < end { end = rng.End } } section := io.NewSectionReader(f, start, end-start+1) dl := &Download{ ReadCloser: &fileSection{Reader: section, closer: f}, Start: start, End: end, Total: size, Meta: FileMeta{ Size: size, ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))), AcceptRanges: true, }, } if end < 0 { // 空文件:End 语义上等于 -1(未知),Total=0 已表达大小 dl.End = -1 } return dl, nil } // fileSection 组合 SectionReader 与文件关闭器。 type fileSection struct { io.Reader closer io.Closer } func (f *fileSection) Close() error { return f.closer.Close() } // Stat 获取文件元信息。 func (l *LocalStorage) Stat(ctx context.Context, savePath string) (*FileMeta, error) { full, err := l.absPath(savePath) if err != nil { return nil, err } info, err := os.Stat(full) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return nil, fmt.Errorf("storage/local: Stat 失败: %w", err) } if info.IsDir() { return nil, ErrNotFound } return &FileMeta{ Size: info.Size(), ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))), AcceptRanges: true, }, nil } // SaveChunk 保存分片到 <父目录>/chunks//.part,原子写入。 func (l *LocalStorage) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) { // 先校验目标路径合法性,分片目录随合法路径派生。 if _, err := l.absPath(savePath); err != nil { return 0, err } chunkRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, chunkIndex)) if !ok { return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, savePath) } full := filepath.Join(l.root, filepath.FromSlash(chunkRel)) if !withinRoot(full, l.root) { return 0, fmt.Errorf("%w: 分片路径非法 %q", ErrInvalidPath, chunkRel) } src := &countingReader{r: r} if err := writeFileAtomic(full, src); err != nil { return src.count(), err } return src.count(), nil } // MergeChunks 按索引 0..total-1 有序合并分片: // - 逐分片流式拷贝到临时输出(边拷贝边计算整文件与分片 SHA256); // - verifyHash 非 nil 时校验分片哈希(空串跳过); // - 全部通过后 fsync + 原子重命名,并清理分片临时目录。 func (l *LocalStorage) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) { if total <= 0 { return 0, "", fmt.Errorf("storage/local: 非法分片总数 %d", total) } full, err := l.absPath(savePath) if err != nil { return 0, "", err } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return 0, "", fmt.Errorf("storage/local: 创建目标目录失败: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(full), "."+filepath.Base(full)+".merging-*") if err != nil { return 0, "", fmt.Errorf("storage/local: 创建临时文件失败: %w", err) } tmpName := tmp.Name() defer func() { _ = tmp.Close() _ = os.Remove(tmpName) // 成功时已被重命名,删除静默失败 }() totalHash := sha256.New() buf := make([]byte, localChunkSize) var size int64 for i := 0; i < total; i++ { if err := ctx.Err(); err != nil { return 0, "", err } partRel, ok := SanitizePath(ChunkPartPath(savePath, uploadID, i)) if !ok { return 0, "", fmt.Errorf("%w: 分片 %d 路径非法", ErrInvalidPath, i) } partPath := filepath.Join(l.root, filepath.FromSlash(partRel)) in, err := os.Open(partPath) if err != nil { return 0, "", fmt.Errorf("storage/local: 分片 %d 不存在: %w", i, err) } chunkHash := sha256.New() n, err := io.CopyBuffer(io.MultiWriter(tmp, totalHash, chunkHash), in, buf) _ = in.Close() if err != nil { return 0, "", fmt.Errorf("storage/local: 读取分片 %d 失败: %w", i, err) } if verifyHash != nil { expected, err := verifyHash(i) if err != nil { return 0, "", err } if expected != "" && expected != hex.EncodeToString(chunkHash.Sum(nil)) { return 0, "", fmt.Errorf("%w: 分片 %d 期望 %s", ErrHashMismatch, i, expected) } } size += n } if err := tmp.Sync(); err != nil { return 0, "", fmt.Errorf("storage/local: 落盘失败: %w", err) } if err := tmp.Close(); err != nil { return 0, "", fmt.Errorf("storage/local: 关闭临时文件失败: %w", err) } if err := os.Rename(tmpName, full); err != nil { return 0, "", fmt.Errorf("storage/local: 原子重命名失败: %w", err) } // 合并成功后清理分片临时目录(静默容错,不掩盖成功结果)。 _ = l.CleanChunks(ctx, uploadID, savePath) return size, hex.EncodeToString(totalHash.Sum(nil)), nil } // CleanChunks 清理分片临时目录;不存在时静默成功,并尝试移除空 chunks 父目录。 func (l *LocalStorage) CleanChunks(ctx context.Context, uploadID string, savePath string) error { dirRel, ok := SanitizePath(chunkDirOf(savePath, uploadID)) if !ok { return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, savePath) } dir := filepath.Join(l.root, filepath.FromSlash(dirRel)) if !withinRoot(dir, l.root) { return fmt.Errorf("%w: 分片目录非法 %q", ErrInvalidPath, dirRel) } if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("storage/local: 清理分片目录失败: %w", err) } // 父级 chunks 目录为空则一并清理(对齐参考实现)。 chunksParent := filepath.Dir(dir) if entries, err := os.ReadDir(chunksParent); err == nil && len(entries) == 0 { _ = os.Remove(chunksParent) } return nil } // chunkDirOf 返回分片目录(去掉文件名部分):<父目录>/chunks/。 func chunkDirOf(savePath, uploadID string) string { cd := ChunkDir(savePath, uploadID) // ChunkDir 返回 "/chunks//",去掉末段文件名即目录。 if idx := strings.LastIndex(cd, "/"); idx > 0 { return cd[:idx] } return cd } // FileExists 检查文件是否存在;非法路径按不存在处理(对齐参考实现)。 func (l *LocalStorage) FileExists(ctx context.Context, savePath string) (bool, error) { full, err := l.absPath(savePath) if err != nil { return false, nil } info, err := os.Stat(full) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, nil } return false, fmt.Errorf("storage/local: Stat 失败: %w", err) } return !info.IsDir(), nil } // HeadMeta 读取文件元信息与前 n 字节(本地引擎实现)。 func (l *LocalStorage) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) { full, err := l.absPath(savePath) if err != nil { return nil, nil, err } f, err := os.Open(full) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil, ErrNotFound } return nil, nil, fmt.Errorf("storage/local: 打开文件失败: %w", err) } defer func() { _ = f.Close() }() info, err := f.Stat() if err != nil { return nil, nil, fmt.Errorf("storage/local: Stat 失败: %w", err) } if info.IsDir() { return nil, nil, ErrNotFound } head := make([]byte, headBytes) n, _ := io.ReadFull(f, head) return &FileMeta{ Size: info.Size(), ContentType: mime.TypeByExtension(strings.ToLower(filepath.Ext(full))), AcceptRanges: true, }, head[:n], nil } // PresignGetURL 本地引擎不支持直链。 func (l *LocalStorage) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) { return "", ErrNotSupported } // PresignPutURL 本地引擎不支持直传。 func (l *LocalStorage) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) { return "", ErrNotSupported } // HealthCheck 健康检查:根目录可写(写入并删除探针文件)。 func (l *LocalStorage) HealthCheck(ctx context.Context) error { if err := os.MkdirAll(l.root, 0o755); err != nil { return fmt.Errorf("%w: 本地存储根目录不可创建: %v", ErrUnavailable, err) } probe := filepath.Join(l.root, ".health-probe") if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil { return fmt.Errorf("%w: 本地存储不可写: %v", ErrUnavailable, err) } _ = os.Remove(probe) return nil } // writeFileAtomic 临时文件 + fsync + rename 的原子落盘。 func writeFileAtomic(dst string, src io.Reader) error { dir := filepath.Dir(dst) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("storage/local: 创建目录失败: %w", err) } tmp, err := os.CreateTemp(dir, "."+filepath.Base(dst)+".tmp-*") if err != nil { return fmt.Errorf("storage/local: 创建临时文件失败: %w", err) } tmpName := tmp.Name() cleanup := func() { _ = tmp.Close(); _ = os.Remove(tmpName) } if _, err := io.CopyBuffer(tmp, src, make([]byte, localChunkSize)); err != nil { cleanup() return fmt.Errorf("storage/local: 写入失败: %w", err) } if err := tmp.Sync(); err != nil { cleanup() return fmt.Errorf("storage/local: fsync 失败: %w", err) } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return fmt.Errorf("storage/local: 关闭临时文件失败: %w", err) } if err := os.Rename(tmpName, dst); err != nil { _ = os.Remove(tmpName) return fmt.Errorf("storage/local: 原子重命名失败: %w", err) } return nil } // countingReader 统计累计读取字节数(并发安全)。 type countingReader struct { r io.Reader n atomic.Int64 } func (c *countingReader) Read(p []byte) (int, error) { n, err := c.r.Read(p) c.n.Add(int64(n)) return n, err } // count 返回累计字节数。 func (c *countingReader) count() int64 { return c.n.Load() } // reset 归零计数(请求体重放时使用)。 func (c *countingReader) reset() { c.n.Store(0) } // 接口编译期断言。 var _ Storage = (*LocalStorage)(nil)