2026-08-15 02:30:36 +08:00
|
|
|
package filestore
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"crypto/subtle"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"hash"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var ErrDestinationConflict = errors.New("destination already exists with different content")
|
|
|
|
|
|
|
|
|
|
// Identity 是不可变文件在进入事务目录前必须满足的身份。
|
|
|
|
|
type Identity struct {
|
|
|
|
|
Size int64
|
|
|
|
|
SHA256 string
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 01:27:30 +08:00
|
|
|
// Validate checks the exact immutable file identity without reading a file.
|
|
|
|
|
func (i Identity) Validate() error {
|
|
|
|
|
_, err := validateIdentity(i)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 02:30:36 +08:00
|
|
|
// File 是一次原子提交的结果。
|
|
|
|
|
type File struct {
|
|
|
|
|
Path string
|
|
|
|
|
Identity Identity
|
|
|
|
|
Reused bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store 在一个 daemon 独占管理的本地根目录中保存不可变文件。
|
|
|
|
|
type Store struct {
|
|
|
|
|
root string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New 创建文件存储并固定其规范根目录。
|
|
|
|
|
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 把内容写入同目录临时文件,校验后通过硬链接原子创建最终文件。
|
|
|
|
|
// 最终文件已经存在且身份相同时按幂等成功处理,内容不同时拒绝覆盖。
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-16 01:27:30 +08:00
|
|
|
target, err := s.destination(relativePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return File{}, err
|
2026-08-15 02:30:36 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 01:27:30 +08:00
|
|
|
// Inspect verifies an immutable destination without changing it.
|
|
|
|
|
// found=false means that the exact destination does not exist.
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 02:30:36 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sameDigest(actual hash.Hash, expected []byte) bool {
|
|
|
|
|
return subtle.ConstantTimeCompare(actual.Sum(nil), expected) == 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|