Add safe immutable directory promotion
This commit is contained in:
204
internal/fileops/directory.go
Normal file
204
internal/fileops/directory.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
promotedDirectoryMode = 0o755
|
||||
promotedFileMode = 0o644
|
||||
)
|
||||
|
||||
// PromoteDirectory copies an existing regular-file tree into a new directory
|
||||
// and installs the complete copy atomically. It never removes the source or
|
||||
// replaces an existing destination.
|
||||
func PromoteDirectory(src, dst string) error {
|
||||
return promoteDirectory(src, dst, renameDirectoryNoReplace)
|
||||
}
|
||||
|
||||
func promoteDirectory(src, dst string, install func(string, string) error) error {
|
||||
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
|
||||
return fmt.Errorf("source and destination directory paths are required")
|
||||
}
|
||||
|
||||
sourceInfo, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect source directory: %w", err)
|
||||
}
|
||||
if !sourceInfo.IsDir() {
|
||||
return fmt.Errorf("source path %q is not a directory", src)
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return fmt.Errorf("destination path %q already exists", dst)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect destination path: %w", err)
|
||||
}
|
||||
|
||||
insideSource, err := pathWithin(src, dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insideSource {
|
||||
return fmt.Errorf("destination path %q must not be inside source directory %q", dst, src)
|
||||
}
|
||||
|
||||
destinationParent := filepath.Dir(dst)
|
||||
parentInfo, err := os.Lstat(destinationParent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect destination parent: %w", err)
|
||||
}
|
||||
if !parentInfo.IsDir() {
|
||||
return fmt.Errorf("destination parent %q is not a directory", destinationParent)
|
||||
}
|
||||
|
||||
temporary, err := os.MkdirTemp(destinationParent, "."+filepath.Base(dst)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary destination directory: %w", err)
|
||||
}
|
||||
removeTemporary := true
|
||||
defer func() {
|
||||
if removeTemporary {
|
||||
_ = os.RemoveAll(temporary)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := copyRegularTree(src, temporary); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(temporary, promotedDirectoryMode); err != nil {
|
||||
return fmt.Errorf("set temporary root permissions: %w", err)
|
||||
}
|
||||
if err := syncDirectory(temporary); err != nil {
|
||||
return fmt.Errorf("sync temporary root: %w", err)
|
||||
}
|
||||
if err := install(temporary, dst); err != nil {
|
||||
return fmt.Errorf("install promoted directory: %w", err)
|
||||
}
|
||||
|
||||
removeTemporary = false
|
||||
_ = syncDirectory(destinationParent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyRegularTree(src, dst string) error {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source directory %q: %w", src, err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
sourcePath := filepath.Join(src, entry.Name())
|
||||
destinationPath := filepath.Join(dst, entry.Name())
|
||||
info, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect source entry %q: %w", sourcePath, err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case info.Mode().IsRegular():
|
||||
if err := copyRegularFile(sourcePath, destinationPath, info); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.IsDir():
|
||||
if err := os.Mkdir(destinationPath, promotedDirectoryMode); err != nil {
|
||||
return fmt.Errorf("create destination directory %q: %w", destinationPath, err)
|
||||
}
|
||||
if err := copyRegularTree(sourcePath, destinationPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(destinationPath, promotedDirectoryMode); err != nil {
|
||||
return fmt.Errorf("set destination directory permissions %q: %w", destinationPath, err)
|
||||
}
|
||||
if err := syncDirectory(destinationPath); err != nil {
|
||||
return fmt.Errorf("sync destination directory %q: %w", destinationPath, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("source entry %q has unsupported file type %s", sourcePath, info.Mode().Type())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyRegularFile(src, dst string, inspected os.FileInfo) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source file %q: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
openedInfo, err := in.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect opened source file %q: %w", src, err)
|
||||
}
|
||||
if !openedInfo.Mode().IsRegular() || !os.SameFile(inspected, openedInfo) {
|
||||
return fmt.Errorf("source file %q changed while being copied", src)
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, promotedFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create destination file %q: %w", dst, err)
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = out.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("copy source file %q: %w", src, err)
|
||||
}
|
||||
if err := out.Chmod(promotedFileMode); err != nil {
|
||||
return fmt.Errorf("set destination file permissions %q: %w", dst, err)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
return fmt.Errorf("sync destination file %q: %w", dst, err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("close destination file %q: %w", dst, err)
|
||||
}
|
||||
closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = directory.Close() }()
|
||||
|
||||
err = directory.Sync()
|
||||
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func pathWithin(parent, candidate string) (bool, error) {
|
||||
absoluteParent, err := filepath.Abs(parent)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve source directory: %w", err)
|
||||
}
|
||||
absoluteCandidate, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve destination directory: %w", err)
|
||||
}
|
||||
relative, err := filepath.Rel(absoluteParent, absoluteCandidate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("compare source and destination directories: %w", err)
|
||||
}
|
||||
return relative != "." && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user