Files
2026-08-17 10:10:14 +08:00

274 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package filestore 在一个由 daemon 独占管理的本地根目录中保存不可变文件,
// 通过内容哈希校验、硬链接原子提交与符号链接逃逸防护,保证已发布文件与其声明身份严格一致。
package filestore
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
)
// ErrDestinationConflict 表示目标位置已存在内容不同的文件,禁止覆盖。
var ErrDestinationConflict = errors.New("destination already exists with different content")
// Identity 不可变文件在进入事务目录前必须满足的身份,由精确大小与 SHA-256 摘要组成。
type Identity struct {
// Size 期望的精确字节数,必须为非负。
Size int64
// SHA256 期望的十六进制 SHA-256 摘要,必须为 64 个十六进制字符。
SHA256 string
}
// Validate 校验 Identity 的大小与 SHA-256 格式是否合法,不读取任何文件。
func (i Identity) Validate() error {
_, err := validateIdentity(i)
return err
}
// File 一次原子提交的返回结果。
type File struct {
// Path 最终文件在存储根目录下的绝对路径。
Path string
// Identity 最终文件满足的精确身份。
Identity Identity
// Reused 为 true 表示目标位置已存在身份相同的文件,本次提交未创建新文件。
Reused bool
}
// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件,并保证所有写入都经过身份校验。
type Store struct {
// root 解析符号链接后的规范根目录绝对路径,所有目标路径都必须落在其中。
root string
}
// New 创建文件存储并固定其规范根目录。root 必须非空,最终会转换为绝对路径、
// 创建目录并解析符号链接,确保后续操作都基于稳定且真实存在的根目录。
func New(root string) (*Store, error) {
if root == "" {
return nil, errors.New("file store root is required")
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve file store root: %w", err)
}
if err := os.MkdirAll(absRoot, 0o750); err != nil {
return nil, fmt.Errorf("create file store root: %w", err)
}
resolvedRoot, err := filepath.EvalSymlinks(absRoot)
if err != nil {
return nil, fmt.Errorf("resolve file store symlinks: %w", err)
}
return &Store{root: resolvedRoot}, nil
}
// Commit 把 source 的内容写入目标目录内的临时文件,校验其大小与 SHA-256 与 expected 一致后,
// 通过硬链接原子地创建 relativePath 指向的最终文件。若最终文件已存在且身份相同,则按幂等
// 成功返回 Reused 为 true 的结果;若身份不同则返回 ErrDestinationConflict,拒绝覆盖。
// 写入过程会同步临时文件与父目录,确保中途失败不会留下已发布的半成品文件。
func (s *Store) Commit(relativePath string, source io.Reader, expected Identity) (File, error) {
if source == nil {
return File{}, errors.New("file source is required")
}
expectedDigest, err := validateIdentity(expected)
if err != nil {
return File{}, err
}
target, err := s.destination(relativePath)
if err != nil {
return File{}, err
}
parent := filepath.Dir(target)
if err := os.MkdirAll(parent, 0o750); err != nil {
return File{}, fmt.Errorf("create destination directory: %w", err)
}
if err := s.verifyParent(parent); err != nil {
return File{}, err
}
if existing, found, err := verifyExisting(target, expected, expectedDigest); err != nil {
return File{}, err
} else if found {
return existing, nil
}
temp, err := os.CreateTemp(parent, ".incoming-*")
if err != nil {
return File{}, fmt.Errorf("create temporary file: %w", err)
}
tempPath := temp.Name()
preserveTemp := false
defer func() {
if !preserveTemp {
_ = os.Remove(tempPath)
}
}()
if err := temp.Chmod(0o640); err != nil {
_ = temp.Close()
return File{}, fmt.Errorf("set temporary file permissions: %w", err)
}
digest := sha256.New()
size, copyErr := io.Copy(io.MultiWriter(temp, digest), source)
if copyErr != nil {
_ = temp.Close()
return File{}, fmt.Errorf("write temporary file: %w", copyErr)
}
if size != expected.Size {
_ = temp.Close()
return File{}, fmt.Errorf("file size mismatch: got %d, want %d", size, expected.Size)
}
if !sameDigest(digest, expectedDigest) {
_ = temp.Close()
return File{}, errors.New("file SHA-256 mismatch")
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return File{}, fmt.Errorf("flush temporary file: %w", err)
}
if err := temp.Close(); err != nil {
return File{}, fmt.Errorf("close temporary file: %w", err)
}
if err := os.Link(tempPath, target); err != nil {
if existing, found, verifyErr := verifyExisting(target, expected, expectedDigest); verifyErr != nil {
return File{}, verifyErr
} else if found {
return existing, nil
}
return File{}, fmt.Errorf("atomically create destination file: %w", err)
}
if err := syncDirectory(parent); err != nil {
// 最终路径已经可见,保留临时硬链接,避免在目录落盘失败时继续改变现场。
preserveTemp = true
return File{}, err
}
if err := os.Remove(tempPath); err != nil {
preserveTemp = true
return File{}, fmt.Errorf("remove committed temporary link: %w", err)
}
if err := syncDirectory(parent); err != nil {
return File{}, err
}
return File{Path: target, Identity: expected}, nil
}
// Inspect 在不做任何修改的情况下校验 relativePath 指向的不可变目标是否与 expected 一致。
// 返回的 found 为 false 表示该目标尚不存在(或所在目录尚不存在);目标存在但身份不符时
// 返回 ErrDestinationConflict。
func (s *Store) Inspect(relativePath string, expected Identity) (file File, found bool, err error) {
expectedDigest, err := validateIdentity(expected)
if err != nil {
return File{}, false, err
}
target, err := s.destination(relativePath)
if err != nil {
return File{}, false, err
}
parent := filepath.Dir(target)
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
return File{}, false, nil
} else if err != nil {
return File{}, false, fmt.Errorf("inspect destination directory: %w", err)
}
if err := s.verifyParent(parent); err != nil {
return File{}, false, err
}
return verifyExisting(target, expected, expectedDigest)
}
// destination 将 relativePath 规范化后拼接到存储根目录,返回最终绝对路径。
// 它拒绝空路径、非本地相对路径(如 ../、绝对路径或含 .. 的越界路径)。
func (s *Store) destination(relativePath string) (string, error) {
if !filepath.IsLocal(relativePath) || relativePath == "." {
return "", fmt.Errorf("file store path is not a local relative path: %q", relativePath)
}
return filepath.Join(s.root, filepath.Clean(relativePath)), nil
}
// verifyParent 解析 parent 的符号链接后,确认其仍位于存储根目录之内,
// 防止通过符号链接把文件写出根目录之外。
func (s *Store) verifyParent(parent string) error {
resolvedParent, err := filepath.EvalSymlinks(parent)
if err != nil {
return fmt.Errorf("resolve destination directory symlinks: %w", err)
}
relative, err := filepath.Rel(s.root, resolvedParent)
if err != nil {
return fmt.Errorf("compare destination directory with store root: %w", err)
}
if relative != "." && !filepath.IsLocal(relative) {
return fmt.Errorf("destination directory escapes file store root: %q", parent)
}
return nil
}
// validateIdentity 校验 identity 的大小非负、SHA-256 为合法 64 位十六进制字符串,
// 并返回解码后的摘要字节切片供后续比较使用。
func validateIdentity(identity Identity) ([]byte, error) {
if identity.Size < 0 {
return nil, errors.New("expected file size must not be negative")
}
digest, err := hex.DecodeString(identity.SHA256)
if err != nil || len(digest) != sha256.Size {
return nil, errors.New("expected SHA-256 must be a 64-character hexadecimal value")
}
return digest, nil
}
// verifyExisting 检查 path 处是否已存在目标文件:不存在时返回 found=false
// 存在但不是普通文件(或为符号链接)、大小不符、摘要不符时分别返回错误或
// ErrDestinationConflict;完全一致时返回 Reused 为 true 的 File。
func verifyExisting(path string, expected Identity, expectedDigest []byte) (File, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return File{}, false, nil
}
if err != nil {
return File{}, false, fmt.Errorf("inspect destination file: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return File{}, false, fmt.Errorf("destination is not a regular file: %s", path)
}
if info.Size() != expected.Size {
return File{}, false, ErrDestinationConflict
}
file, err := os.Open(path)
if err != nil {
return File{}, false, fmt.Errorf("open existing destination file: %w", err)
}
digest := sha256.New()
_, copyErr := io.Copy(digest, file)
closeErr := file.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return File{}, false, fmt.Errorf("hash existing destination file: %w", err)
}
if !sameDigest(digest, expectedDigest) {
return File{}, false, ErrDestinationConflict
}
return File{Path: path, Identity: expected, Reused: true}, true, nil
}
// sameDigest 使用常数时间比较判断实际摘要与期望摘要是否一致,避免时序侧信道。
func sameDigest(actual hash.Hash, expected []byte) bool {
return subtle.ConstantTimeCompare(actual.Sum(nil), expected) == 1
}
// syncDirectory 打开 path 指向的目录并调用 Sync 将其刷入磁盘,保证目录项变更持久化。
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory for flush: %w", err)
}
syncErr := directory.Sync()
closeErr := directory.Close()
if err := errors.Join(syncErr, closeErr); err != nil {
return fmt.Errorf("flush directory: %w", err)
}
return nil
}