Confine cleanup and use held session locks

This commit is contained in:
2026-08-10 18:14:09 +00:00
parent 18ddf00d3d
commit 363313d99c
25 changed files with 919 additions and 72 deletions

165
internal/fileops/cleanup.go Normal file
View File

@@ -0,0 +1,165 @@
package fileops
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// RemoveAllUnderRoot removes target and its contents only after opening root and
// every target ancestor without following symbolic links. target must name a
// proper descendant of root. Symlinks are rejected rather than followed or
// removed so a failed cleanup never silently changes its scope.
func RemoveAllUnderRoot(rootPath, target string) error {
root, targetName, err := openConfinedCleanupTarget(rootPath, target)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
return removeConfinedEntry(root, targetName)
}
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
if strings.TrimSpace(rootPath) == "" {
return nil, "", fmt.Errorf("cleanup root is required")
}
if strings.TrimSpace(target) == "" {
return nil, "", fmt.Errorf("cleanup target is required")
}
rootAbs, err := filepath.Abs(rootPath)
if err != nil {
return nil, "", fmt.Errorf("resolve cleanup root: %w", err)
}
targetAbs, err := filepath.Abs(target)
if err != nil {
return nil, "", fmt.Errorf("resolve cleanup target: %w", err)
}
relative, err := filepath.Rel(rootAbs, targetAbs)
if err != nil {
return nil, "", fmt.Errorf("resolve cleanup target below root: %w", err)
}
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf("cleanup target %q must be below root %q", targetAbs, rootAbs)
}
parts, err := relativePathParts(relative)
if err != nil {
return nil, "", err
}
root, err := openConfinedDirectory(rootAbs)
if err != nil {
return nil, "", err
}
for _, part := range parts[:len(parts)-1] {
child, err := openConfinedChild(root, part, false, 0)
if err != nil {
_ = root.Close()
return nil, "", err
}
_ = root.Close()
root = child
}
return root, parts[len(parts)-1], nil
}
func openConfinedDirectory(path string) (*os.Root, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve directory: %w", err)
}
parts, err := absolutePathParts(abs)
if err != nil {
return nil, err
}
rootPath := filesystemRoot(abs)
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open filesystem root %q: %w", rootPath, err)
}
for _, part := range parts {
child, err := openConfinedChild(root, part, false, 0)
if err != nil {
_ = root.Close()
return nil, err
}
_ = root.Close()
root = child
}
return root, nil
}
func relativePathParts(path string) ([]string, error) {
parts := strings.FieldsFunc(path, func(r rune) bool { return r == filepath.Separator || r == '/' || r == '\\' })
if len(parts) == 0 {
return nil, fmt.Errorf("cleanup target is required")
}
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return nil, fmt.Errorf("unsafe cleanup path component %q", part)
}
}
return parts, nil
}
func removeConfinedEntry(parent *os.Root, name string) error {
info, err := parent.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("inspect cleanup entry %q: %w", name, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to delete symlink path %q", name)
}
if !info.IsDir() {
if err := parent.Remove(name); err != nil {
return fmt.Errorf("remove cleanup entry %q: %w", name, err)
}
return nil
}
child, err := openConfinedChild(parent, name, false, 0)
if err != nil {
return err
}
err = removeConfinedChildren(child)
closeErr := child.Close()
if err != nil {
if closeErr != nil {
return errors.Join(err, closeErr)
}
return err
}
if closeErr != nil {
return fmt.Errorf("close cleanup directory %q: %w", name, closeErr)
}
if err := parent.Remove(name); err != nil {
return fmt.Errorf("remove cleanup directory %q: %w", name, err)
}
return nil
}
func removeConfinedChildren(root *os.Root) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open cleanup directory: %w", err)
}
entries, readErr := directory.ReadDir(-1)
closeErr := directory.Close()
if readErr != nil {
return fmt.Errorf("read cleanup directory: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("close cleanup directory: %w", closeErr)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if err := removeConfinedEntry(root, entry.Name()); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,79 @@
package fileops
import (
"os"
"path/filepath"
"testing"
)
func TestRemoveAllUnderRootRemovesNestedTreeIdempotently(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "runs", "run-1")
mustWriteFile(t, filepath.Join(target, "nested", "result.txt"), []byte("result"), 0o640)
if err := RemoveAllUnderRoot(root, target); err != nil {
t.Fatalf("RemoveAllUnderRoot() error = %v", err)
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("Lstat(%q) error = %v, want not exist", target, err)
}
if err := RemoveAllUnderRoot(root, target); err != nil {
t.Fatalf("second RemoveAllUnderRoot() error = %v", err)
}
}
func TestRemoveAllUnderRootRejectsUnsafeTargets(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
target := filepath.Join(root, "runs", "run-1")
mustWriteFile(t, filepath.Join(target, "result.txt"), []byte("result"), 0o640)
sentinel := filepath.Join(outside, "sentinel.txt")
mustWriteFile(t, sentinel, []byte("outside"), 0o640)
if err := RemoveAllUnderRoot(root, root); err == nil {
t.Fatal("RemoveAllUnderRoot(root, root) error = nil, want root rejection")
}
if err := RemoveAllUnderRoot(root, filepath.Join(outside, "target")); err == nil {
t.Fatal("RemoveAllUnderRoot(outside) error = nil, want outside-root rejection")
}
if err := os.Symlink(outside, filepath.Join(root, "runs")); err == nil {
t.Fatal("Symlink() error = nil, want collision because runs already exists")
}
if err := os.RemoveAll(filepath.Join(root, "runs")); err != nil {
t.Fatalf("RemoveAll(runs) error = %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "runs")); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
if err := RemoveAllUnderRoot(root, target); err == nil {
t.Fatal("RemoveAllUnderRoot(symlinked ancestor) error = nil, want rejection")
}
data, err := os.ReadFile(sentinel)
if err != nil {
t.Fatalf("ReadFile(sentinel) error = %v", err)
}
if string(data) != "outside" {
t.Fatalf("sentinel content = %q, want outside", data)
}
}
func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
target := filepath.Join(root, "runs", "run-1")
if err := os.MkdirAll(target, 0o750); err != nil {
t.Fatalf("MkdirAll(target) error = %v", err)
}
sentinel := filepath.Join(outside, "sentinel.txt")
mustWriteFile(t, sentinel, []byte("outside"), 0o640)
if err := os.Symlink(outside, filepath.Join(target, "link")); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
if err := RemoveAllUnderRoot(root, target); err == nil {
t.Fatal("RemoveAllUnderRoot() error = nil, want symlink rejection")
}
if _, err := os.Stat(sentinel); err != nil {
t.Fatalf("outside sentinel was changed: %v", err)
}
}

View File

@@ -183,6 +183,39 @@ func syncOpenedDirectory(parent *os.Root) error {
return syncDirectoryFile(directory, parent.Name())
}
// OpenFileConfined opens a file after verifying its parent hierarchy without
// following symbolic links. Existing symbolic-link leaves are rejected.
func OpenFileConfined(path string, flags int, mode os.FileMode) (*os.File, error) {
parent, name, err := openConfinedParent(path, false, 0)
if err != nil {
return nil, err
}
defer func() { _ = parent.Close() }()
if info, err := parent.Lstat(name); err == nil && info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("destination file %q is a symbolic link", name)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect destination file %q: %w", name, err)
}
file, err := parent.OpenFile(name, flags, mode)
if err != nil {
return nil, err
}
opened, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("inspect opened destination file %q: %w", name, err)
}
current, err := parent.Lstat(name)
if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
_ = file.Close()
if err != nil {
return nil, fmt.Errorf("reinspect destination file %q: %w", name, err)
}
return nil, fmt.Errorf("destination file %q changed while being opened", name)
}
return file, nil
}
// DownloadedTempFile is a completed, destination-confined temporary file. It
// keeps its parent directory open until the caller installs or cleans it up.
type DownloadedTempFile struct {

View File

@@ -1,6 +1,8 @@
package fileops
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -21,14 +23,257 @@ func PromoteDirectory(src, dst string) error {
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
return err
}
parent, _, err := openConfinedParent(dst, false, 0)
parent, destinationName, err := openConfinedParent(dst, false, 0)
if err != nil {
return fmt.Errorf("open destination parent: %w", err)
}
if err := parent.Close(); err != nil {
return fmt.Errorf("close 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")
}
return promoteDirectory(src, dst, renameDirectoryNoReplace)
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 {
@@ -87,7 +332,7 @@ func promoteDirectoryWithHooks(
removeTemporary := true
defer func() {
if removeTemporary {
_ = os.RemoveAll(temporary)
_ = RemoveAllUnderRoot(destinationParent, temporary)
}
}()

View File

@@ -2,10 +2,18 @@
package fileops
import "golang.org/x/sys/unix"
import (
"os"
"golang.org/x/sys/unix"
)
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
return unix.RenamexNp(src, dst, unix.RENAME_EXCL)
}
func renameDirectoryNoReplaceAt(parent *os.File, src, dst string) error {
return unix.RenameatxNp(int(parent.Fd()), src, int(parent.Fd()), dst, unix.RENAME_EXCL)
}

View File

@@ -2,10 +2,18 @@
package fileops
import "golang.org/x/sys/unix"
import (
"os"
"golang.org/x/sys/unix"
)
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
return unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
}
func renameDirectoryNoReplaceAt(parent *os.File, src, dst string) error {
return unix.Renameat2(int(parent.Fd()), src, int(parent.Fd()), dst, unix.RENAME_NOREPLACE)
}

View File

@@ -4,6 +4,7 @@ package fileops
import (
"fmt"
"os"
"runtime"
)
@@ -14,3 +15,7 @@ func checkAtomicDirectoryPromotionSupport() error {
func renameDirectoryNoReplace(_, _ string) error {
return checkAtomicDirectoryPromotionSupport()
}
func renameDirectoryNoReplaceAt(_ *os.File, _, _ string) error {
return checkAtomicDirectoryPromotionSupport()
}

View File

@@ -2,18 +2,17 @@
package fileops
import "golang.org/x/sys/windows"
import (
"fmt"
"os"
)
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
from, err := windows.UTF16PtrFromString(src)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(dst)
if err != nil {
return err
}
return windows.MoveFileEx(from, to, 0)
func checkAtomicDirectoryPromotionSupport() error {
return fmt.Errorf("%w on windows", ErrAtomicDirectoryPromotionUnsupported)
}
func renameDirectoryNoReplace(src, dst string) error { return checkAtomicDirectoryPromotionSupport() }
func renameDirectoryNoReplaceAt(parent *os.File, src, dst string) error {
return checkAtomicDirectoryPromotionSupport()
}