Centralize path-safe root joins and atomic file operations

This commit is contained in:
2026-05-23 15:45:31 +00:00
parent 98649f4d81
commit 094b0d2532
11 changed files with 466 additions and 189 deletions

View File

@@ -9,6 +9,9 @@ import (
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// ErrLockConflict is returned when a session lock already exists.
@@ -101,7 +104,7 @@ func (s *LocalStore) copyInputWithPaths(paths SessionPaths, sessionID, srcPath,
return Ref{}, fmt.Errorf("copy input: %w", err)
}
if err := copyFileAtomic(srcPath, destAbs, 0o644); err != nil {
if err := fileops.CopyFileAtomic(srcPath, destAbs, 0o644); err != nil {
return Ref{}, fmt.Errorf("copy input %q -> %q: %w", srcPath, destAbs, err)
}
@@ -145,45 +148,9 @@ func (s *LocalStore) WriteFileAtomic(path string, data []byte, perm os.FileMode)
if strings.TrimSpace(path) == "" {
return fmt.Errorf("write file atomic: path is required")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("write file atomic: create parent dir %q: %w", dir, err)
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
return fmt.Errorf("write file atomic: %w", err)
}
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("write file atomic: create temp file: %w", err)
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write file atomic: write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("write file atomic: sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write file atomic: close temp file: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
return fmt.Errorf("write file atomic: chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("write file atomic: rename temp file: %w", err)
}
removeTmp = false
return nil
}
@@ -256,62 +223,21 @@ func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error {
}
func resolveInRoot(root, relative string) (string, error) {
rel := filepath.Clean(relative)
if rel == "." || rel == "" {
joined, err := pathsafe.JoinSlashRelativeUnderRoot(root, filepath.ToSlash(relative))
if err != nil {
switch {
case errors.Is(err, pathsafe.ErrRelativePathRequired):
return "", fmt.Errorf("relative destination path is required")
case errors.Is(err, pathsafe.ErrRelativePathAbsolute):
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
case errors.Is(err, pathsafe.ErrRelativePathEscape):
return "", fmt.Errorf("relative destination escapes root: %q", relative)
default:
return "", fmt.Errorf("resolve destination in root: %w", err)
}
}
if strings.TrimSpace(joined) == "" {
return "", fmt.Errorf("relative destination path is required")
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("relative destination escapes root: %q", relative)
}
return filepath.Join(root, rel), nil
}
func copyFileAtomic(srcPath, dstPath string, perm os.FileMode) error {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
dir := filepath.Dir(dstPath)
base := filepath.Base(dstPath)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := io.Copy(tmp, src); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, perm); err != nil {
return err
}
if err := os.Rename(tmpName, dstPath); err != nil {
return err
}
removeTmp = false
return nil
return joined, nil
}