556 lines
17 KiB
Go
556 lines
17 KiB
Go
package fileops
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ErrAtomicDirectoryPromotionUnsupported indicates that the current operating
|
|
// system lacks the atomic no-replace primitive required by PromoteDirectory.
|
|
var ErrAtomicDirectoryPromotionUnsupported = errors.New("atomic no-replace directory promotion is unsupported")
|
|
|
|
// 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 {
|
|
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
|
|
return err
|
|
}
|
|
parent, destinationName, err := openConfinedParent(dst, false, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("open destination parent: %w", err)
|
|
}
|
|
defer func() { _ = parent.Close() }()
|
|
return promoteDirectoryConfined(src, dst, parent, destinationName, sourceTraversalHooks{})
|
|
}
|
|
|
|
func promoteDirectoryConfined(src, dst string, parent *os.Root, destinationName string, hooks sourceTraversalHooks) (resultErr 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.Mode()&os.ModeSymlink != 0 || !sourceInfo.IsDir() {
|
|
return fmt.Errorf("source path %q is not a directory", src)
|
|
}
|
|
if _, err := parent.Lstat(destinationName); 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)
|
|
}
|
|
|
|
temporary, temporaryName, err := createSiblingTempDirectory(parent, destinationName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
removeTemporary := true
|
|
defer func() {
|
|
var closeErr error
|
|
if temporary != nil {
|
|
closeErr = temporary.Close()
|
|
}
|
|
if removeTemporary {
|
|
removeErr := removeConfinedEntry(parent, temporaryName)
|
|
if removeErr != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary destination directory: %w", removeErr))
|
|
}
|
|
}
|
|
if closeErr != nil {
|
|
resultErr = errors.Join(resultErr, fmt.Errorf("close temporary destination directory: %w", closeErr))
|
|
}
|
|
}()
|
|
|
|
sourceRoot, err := openVerifiedSourceRoot(src, sourceInfo, hooks)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = sourceRoot.Close() }()
|
|
if err := copyRegularTreeToRoot(sourceRoot, src, temporary, hooks); err != nil {
|
|
return err
|
|
}
|
|
if err := setOpenedDirectoryMode(temporary, WorkspaceDirectoryMode); err != nil {
|
|
return fmt.Errorf("set temporary root permissions: %w", err)
|
|
}
|
|
if err := syncOpenedDirectory(temporary); err != nil {
|
|
return fmt.Errorf("sync temporary root: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("close temporary root: %w", err)
|
|
}
|
|
temporary = nil
|
|
|
|
directory, err := parent.Open(".")
|
|
if err != nil {
|
|
return fmt.Errorf("open destination parent for promotion: %w", err)
|
|
}
|
|
renameErr := renameDirectoryNoReplaceAt(directory, temporaryName, destinationName)
|
|
closeErr := directory.Close()
|
|
if renameErr != nil {
|
|
return fmt.Errorf("install promoted directory: %w", renameErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close destination parent after promotion: %w", closeErr)
|
|
}
|
|
removeTemporary = false
|
|
if err := syncOpenedDirectory(parent); err != nil {
|
|
return fmt.Errorf("sync destination parent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createSiblingTempDirectory(parent *os.Root, base string) (*os.Root, string, error) {
|
|
for attempt := 0; attempt < 100; attempt++ {
|
|
var randomBytes [16]byte
|
|
if _, err := rand.Read(randomBytes[:]); err != nil {
|
|
return nil, "", fmt.Errorf("generate temporary directory name: %w", err)
|
|
}
|
|
name := "." + base + ".tmp-" + hex.EncodeToString(randomBytes[:])
|
|
if err := parent.Mkdir(name, WorkspaceDirectoryMode.Perm()); err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
continue
|
|
}
|
|
return nil, "", fmt.Errorf("create temporary destination directory: %w", err)
|
|
}
|
|
directory, err := openConfinedChild(parent, name, false, 0)
|
|
if err != nil {
|
|
_ = parent.Remove(name)
|
|
return nil, "", err
|
|
}
|
|
return directory, name, nil
|
|
}
|
|
return nil, "", fmt.Errorf("create unique temporary destination directory")
|
|
}
|
|
|
|
func copyRegularTreeToRoot(src *os.Root, sourcePath string, dst *os.Root, hooks sourceTraversalHooks) error {
|
|
directory, err := src.Open(".")
|
|
if err != nil {
|
|
return fmt.Errorf("open source directory %q for traversal: %w", sourcePath, err)
|
|
}
|
|
entries, readErr := directory.ReadDir(-1)
|
|
closeErr := directory.Close()
|
|
if readErr != nil {
|
|
return fmt.Errorf("read source directory %q: %w", sourcePath, readErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close source directory %q: %w", sourcePath, closeErr)
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
for _, entry := range entries {
|
|
entryPath := filepath.Join(sourcePath, entry.Name())
|
|
info, err := src.Lstat(entry.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("inspect source entry %q: %w", entryPath, err)
|
|
}
|
|
if hooks.afterEntryInspect != nil {
|
|
hooks.afterEntryInspect(entryPath)
|
|
}
|
|
switch {
|
|
case info.Mode().IsRegular():
|
|
if err := copyRegularFileToRoot(src, dst, entry.Name(), entryPath, info); err != nil {
|
|
return err
|
|
}
|
|
case info.IsDir():
|
|
if err := copyRegularDirectoryToRoot(src, dst, entry.Name(), entryPath, info, hooks); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("source entry %q has unsupported file type %s", entryPath, info.Mode().Type())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyRegularDirectoryToRoot(sourceParent, destinationParent *os.Root, name, sourcePath string, inspected os.FileInfo, hooks sourceTraversalHooks) error {
|
|
source, err := openVerifiedChildDirectory(sourceParent, name, sourcePath, inspected)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = source.Close() }()
|
|
if err := destinationParent.Mkdir(name, WorkspaceDirectoryMode.Perm()); err != nil {
|
|
return fmt.Errorf("create destination directory %q: %w", sourcePath, err)
|
|
}
|
|
destination, err := openConfinedChild(destinationParent, name, false, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = copyRegularTreeToRoot(source, sourcePath, destination, hooks)
|
|
if err == nil {
|
|
err = setOpenedDirectoryMode(destination, WorkspaceDirectoryMode)
|
|
}
|
|
if err == nil {
|
|
err = syncOpenedDirectory(destination)
|
|
}
|
|
closeErr := destination.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("close destination directory %q: %w", sourcePath, closeErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyRegularFileToRoot(sourceRoot, destinationRoot *os.Root, name, sourcePath string, inspected os.FileInfo) error {
|
|
in, err := openVerifiedSourceFile(sourceRoot, name, sourcePath, inspected)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = in.Close() }()
|
|
out, err := destinationRoot.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, WorkspaceFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("create destination file %q: %w", sourcePath, err)
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = out.Close()
|
|
return fmt.Errorf("copy source file %q: %w", sourcePath, err)
|
|
}
|
|
if err := out.Chmod(WorkspaceFileMode); err != nil {
|
|
_ = out.Close()
|
|
return fmt.Errorf("set destination file permissions %q: %w", sourcePath, err)
|
|
}
|
|
if err := out.Sync(); err != nil {
|
|
_ = out.Close()
|
|
return fmt.Errorf("sync destination file %q: %w", sourcePath, err)
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
return fmt.Errorf("close destination file %q: %w", sourcePath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openVerifiedChildDirectory(parent *os.Root, name, sourcePath string, inspected os.FileInfo) (*os.Root, error) {
|
|
child, err := parent.OpenRoot(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open source directory %q: %w", sourcePath, err)
|
|
}
|
|
opened, err := child.Stat(".")
|
|
if err != nil {
|
|
_ = child.Close()
|
|
return nil, fmt.Errorf("inspect opened source directory %q: %w", sourcePath, err)
|
|
}
|
|
current, err := parent.Lstat(name)
|
|
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || !os.SameFile(opened, current) {
|
|
_ = child.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reinspect source directory %q: %w", sourcePath, err)
|
|
}
|
|
return nil, fmt.Errorf("source directory %q changed while being copied", sourcePath)
|
|
}
|
|
return child, nil
|
|
}
|
|
|
|
func openVerifiedSourceFile(root *os.Root, name, sourcePath string, inspected os.FileInfo) (*os.File, error) {
|
|
in, err := root.Open(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open source file %q: %w", sourcePath, err)
|
|
}
|
|
opened, err := in.Stat()
|
|
if err != nil {
|
|
_ = in.Close()
|
|
return nil, fmt.Errorf("inspect opened source file %q: %w", sourcePath, err)
|
|
}
|
|
current, err := root.Lstat(name)
|
|
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
|
|
_ = in.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reinspect source file %q: %w", sourcePath, err)
|
|
}
|
|
return nil, fmt.Errorf("source file %q changed while being copied", sourcePath)
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
func promoteDirectory(src, dst string, install func(string, string) error) error {
|
|
return promoteDirectoryWithHooks(src, dst, install, sourceTraversalHooks{})
|
|
}
|
|
|
|
type sourceTraversalHooks struct {
|
|
afterRootInspect func()
|
|
afterEntryInspect func(string)
|
|
}
|
|
|
|
func promoteDirectoryWithHooks(
|
|
src, dst string,
|
|
install func(string, string) error,
|
|
hooks sourceTraversalHooks,
|
|
) 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 {
|
|
_ = RemoveAllUnderRoot(destinationParent, temporary)
|
|
}
|
|
}()
|
|
|
|
sourceRoot, err := openVerifiedSourceRoot(src, sourceInfo, hooks)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = sourceRoot.Close() }()
|
|
|
|
if err := copyRegularTree(sourceRoot, src, temporary, hooks); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(temporary, WorkspaceDirectoryMode); 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
|
|
if err := syncDirectory(destinationParent); err != nil {
|
|
return fmt.Errorf("sync destination parent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openVerifiedSourceRoot(path string, inspected os.FileInfo, hooks sourceTraversalHooks) (*os.Root, error) {
|
|
if hooks.afterRootInspect != nil {
|
|
hooks.afterRootInspect()
|
|
}
|
|
|
|
root, err := os.OpenRoot(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open source directory %q: %w", path, err)
|
|
}
|
|
verified := false
|
|
defer func() {
|
|
if !verified {
|
|
_ = root.Close()
|
|
}
|
|
}()
|
|
|
|
opened, err := root.Stat(".")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect opened source directory %q: %w", path, err)
|
|
}
|
|
if !opened.IsDir() || !os.SameFile(inspected, opened) {
|
|
return nil, fmt.Errorf("source directory %q changed while being opened", path)
|
|
}
|
|
current, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reinspect source directory %q: %w", path, err)
|
|
}
|
|
if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || !os.SameFile(opened, current) {
|
|
return nil, fmt.Errorf("source directory %q changed while being opened", path)
|
|
}
|
|
|
|
verified = true
|
|
return root, nil
|
|
}
|
|
|
|
func copyRegularTree(src *os.Root, sourcePath, dst string, hooks sourceTraversalHooks) error {
|
|
directory, err := src.Open(".")
|
|
if err != nil {
|
|
return fmt.Errorf("open source directory %q for traversal: %w", sourcePath, err)
|
|
}
|
|
defer func() { _ = directory.Close() }()
|
|
|
|
entries, err := directory.ReadDir(-1)
|
|
if err != nil {
|
|
return fmt.Errorf("read source directory %q: %w", sourcePath, err)
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
|
|
for _, entry := range entries {
|
|
entryPath := filepath.Join(sourcePath, entry.Name())
|
|
destinationPath := filepath.Join(dst, entry.Name())
|
|
info, err := src.Lstat(entry.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("inspect source entry %q: %w", entryPath, err)
|
|
}
|
|
|
|
switch {
|
|
case info.Mode().IsRegular():
|
|
if hooks.afterEntryInspect != nil {
|
|
hooks.afterEntryInspect(entryPath)
|
|
}
|
|
if err := copyRegularFile(src, entry.Name(), entryPath, destinationPath, info); err != nil {
|
|
return err
|
|
}
|
|
case info.IsDir():
|
|
if hooks.afterEntryInspect != nil {
|
|
hooks.afterEntryInspect(entryPath)
|
|
}
|
|
if err := copyRegularDirectory(src, entry.Name(), entryPath, destinationPath, info, hooks); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("source entry %q has unsupported file type %s", entryPath, info.Mode().Type())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func copyRegularDirectory(
|
|
parent *os.Root,
|
|
name, sourcePath, dst string,
|
|
inspected os.FileInfo,
|
|
hooks sourceTraversalHooks,
|
|
) error {
|
|
child, err := parent.OpenRoot(name)
|
|
if err != nil {
|
|
return fmt.Errorf("open source directory %q: %w", sourcePath, err)
|
|
}
|
|
defer func() { _ = child.Close() }()
|
|
|
|
opened, err := child.Stat(".")
|
|
if err != nil {
|
|
return fmt.Errorf("inspect opened source directory %q: %w", sourcePath, err)
|
|
}
|
|
if !opened.IsDir() || !os.SameFile(inspected, opened) {
|
|
return fmt.Errorf("source directory %q changed while being copied", sourcePath)
|
|
}
|
|
current, err := parent.Lstat(name)
|
|
if err != nil {
|
|
return fmt.Errorf("reinspect source directory %q: %w", sourcePath, err)
|
|
}
|
|
if current.Mode()&os.ModeSymlink != 0 || !current.IsDir() || !os.SameFile(opened, current) {
|
|
return fmt.Errorf("source directory %q changed while being copied", sourcePath)
|
|
}
|
|
|
|
if err := os.Mkdir(dst, WorkspaceDirectoryMode); err != nil {
|
|
return fmt.Errorf("create destination directory %q: %w", dst, err)
|
|
}
|
|
if err := copyRegularTree(child, sourcePath, dst, hooks); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(dst, WorkspaceDirectoryMode); err != nil {
|
|
return fmt.Errorf("set destination directory permissions %q: %w", dst, err)
|
|
}
|
|
if err := syncDirectory(dst); err != nil {
|
|
return fmt.Errorf("sync destination directory %q: %w", dst, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyRegularFile(
|
|
root *os.Root,
|
|
name, sourcePath, dst string,
|
|
inspected os.FileInfo,
|
|
) error {
|
|
in, err := root.Open(name)
|
|
if err != nil {
|
|
return fmt.Errorf("open source file %q: %w", sourcePath, err)
|
|
}
|
|
defer func() { _ = in.Close() }()
|
|
|
|
opened, err := in.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("inspect opened source file %q: %w", sourcePath, err)
|
|
}
|
|
if !opened.Mode().IsRegular() || !os.SameFile(inspected, opened) {
|
|
return fmt.Errorf("source file %q changed while being copied", sourcePath)
|
|
}
|
|
current, err := root.Lstat(name)
|
|
if err != nil {
|
|
return fmt.Errorf("reinspect source file %q: %w", sourcePath, err)
|
|
}
|
|
if current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
|
|
return fmt.Errorf("source file %q changed while being copied", sourcePath)
|
|
}
|
|
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, WorkspaceFileMode)
|
|
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", sourcePath, err)
|
|
}
|
|
if err := out.Chmod(WorkspaceFileMode); 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 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
|
|
}
|