Confine local file installation paths

This commit is contained in:
2026-08-10 17:59:29 +00:00
parent 59f3fe3d1d
commit 18ddf00d3d
20 changed files with 694 additions and 153 deletions

View File

@@ -0,0 +1,413 @@
package fileops
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// openConfinedParent opens dst's parent without following a symbolic-link
// component. It starts from the filesystem root, so the resulting handle stays
// valid even if a pathname ancestor is later renamed or replaced.
func openConfinedParent(dst string, create bool, mode os.FileMode) (*os.Root, string, error) {
if strings.TrimSpace(dst) == "" {
return nil, "", fmt.Errorf("destination path is required")
}
abs, err := filepath.Abs(dst)
if err != nil {
return nil, "", fmt.Errorf("resolve destination path: %w", err)
}
parts, err := absolutePathParts(abs)
if err != nil {
return nil, "", err
}
if len(parts) == 0 {
return nil, "", fmt.Errorf("destination file name is required")
}
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[:len(parts)-1] {
child, err := openConfinedChild(root, part, create, mode)
if err != nil {
_ = root.Close()
return nil, "", err
}
_ = root.Close()
root = child
}
return root, parts[len(parts)-1], nil
}
func ensureConfinedDirectory(directory string, mode os.FileMode) error {
abs, err := filepath.Abs(directory)
if err != nil {
return fmt.Errorf("resolve workspace directory: %w", err)
}
parts, err := absolutePathParts(abs)
if err != nil {
return err
}
rootPath := filesystemRoot(abs)
root, err := os.OpenRoot(rootPath)
if err != nil {
return fmt.Errorf("open filesystem root %q: %w", rootPath, err)
}
defer func() { _ = root.Close() }()
for _, part := range parts {
child, err := openConfinedChild(root, part, true, mode)
if err != nil {
return err
}
_ = root.Close()
root = child
}
return setOpenedDirectoryMode(root, mode)
}
func absolutePathParts(path string) ([]string, error) {
root := filesystemRoot(path)
relative, err := filepath.Rel(root, path)
if err != nil {
return nil, fmt.Errorf("resolve path below filesystem root: %w", err)
}
if relative == "." {
return nil, nil
}
parts := strings.FieldsFunc(relative, func(r rune) bool { return r == filepath.Separator || r == '/' || r == '\\' })
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return nil, fmt.Errorf("unsafe filesystem path component %q", part)
}
}
return parts, nil
}
func filesystemRoot(path string) string {
volume := filepath.VolumeName(path)
if volume == "" {
return string(filepath.Separator)
}
return volume + string(filepath.Separator)
}
func openConfinedChild(parent *os.Root, name string, create bool, mode os.FileMode) (*os.Root, error) {
info, err := parent.Lstat(name)
created := false
if errors.Is(err, os.ErrNotExist) {
if !create {
return nil, fmt.Errorf("destination ancestor %q does not exist", name)
}
if err := parent.Mkdir(name, mode.Perm()); err != nil && !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("create destination ancestor %q: %w", name, err)
}
created = true
info, err = parent.Lstat(name)
}
if err != nil {
return nil, fmt.Errorf("inspect destination ancestor %q: %w", name, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return nil, fmt.Errorf("destination ancestor %q is not a regular directory", name)
}
child, err := parent.OpenRoot(name)
if err != nil {
return nil, fmt.Errorf("open destination ancestor %q: %w", name, err)
}
opened, err := child.Stat(".")
if err != nil {
_ = child.Close()
return nil, fmt.Errorf("inspect opened destination ancestor %q: %w", name, 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 destination ancestor %q: %w", name, err)
}
return nil, fmt.Errorf("destination ancestor %q changed while being opened", name)
}
if created {
if err := setOpenedDirectoryMode(child, mode); err != nil {
_ = child.Close()
return nil, fmt.Errorf("set destination ancestor permissions %q: %w", name, err)
}
}
return child, nil
}
func setOpenedDirectoryMode(root *os.Root, mode os.FileMode) error {
directory, err := root.Open(".")
if err != nil {
return err
}
defer func() { _ = directory.Close() }()
return directory.Chmod(mode)
}
func createSiblingTemp(parent *os.Root, base string) (*os.File, 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 file name: %w", err)
}
name := "." + base + ".tmp-" + hex.EncodeToString(randomBytes[:])
file, err := parent.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return nil, "", err
}
return file, name, nil
}
return nil, "", fmt.Errorf("create unique sibling temporary file")
}
func syncOpenedDirectory(parent *os.Root) error {
directory, err := parent.Open(".")
if err != nil {
return err
}
defer func() { _ = directory.Close() }()
return syncDirectoryFile(directory, parent.Name())
}
// 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 {
parent *os.Root
name string
}
// DownloadToSiblingTemp writes a remote object into a sibling temporary file.
// The caller supplies transport behavior through download and retains object
// identity and validation policy. The destination parent must already exist.
func DownloadToSiblingTemp(destination string, download func(io.Writer) error) (*DownloadedTempFile, error) {
if download == nil {
return nil, fmt.Errorf("download function is required")
}
parent, name, err := openConfinedParent(destination, false, 0)
if err != nil {
return nil, err
}
file, tempName, err := createSiblingTemp(parent, name)
if err != nil {
_ = parent.Close()
return nil, fmt.Errorf("create sibling temporary file: %w", err)
}
if err := download(file); err != nil {
_ = file.Close()
_ = parent.Remove(tempName)
_ = parent.Close()
return nil, err
}
if err := file.Sync(); err != nil {
_ = file.Close()
_ = parent.Remove(tempName)
_ = parent.Close()
return nil, fmt.Errorf("sync downloaded temporary file: %w", err)
}
if err := file.Close(); err != nil {
_ = parent.Remove(tempName)
_ = parent.Close()
return nil, fmt.Errorf("close downloaded temporary file: %w", err)
}
return &DownloadedTempFile{parent: parent, name: tempName}, nil
}
// Open returns a read handle for validation before installation.
func (f *DownloadedTempFile) Open() (*os.File, error) {
if f == nil || f.parent == nil || f.name == "" {
return nil, fmt.Errorf("downloaded temporary file is unavailable")
}
return f.parent.Open(f.name)
}
// Install atomically replaces destinationName in the opened parent directory.
func (f *DownloadedTempFile) Install(destinationName string, mode os.FileMode) error {
if f == nil || f.parent == nil || f.name == "" {
return fmt.Errorf("downloaded temporary file is unavailable")
}
if filepath.Base(destinationName) != destinationName || destinationName == "." || destinationName == "" {
return fmt.Errorf("destination file name is required")
}
file, err := f.parent.Open(f.name)
if err != nil {
return fmt.Errorf("open downloaded temporary file: %w", err)
}
if err := file.Chmod(mode); err != nil {
_ = file.Close()
return fmt.Errorf("set downloaded temporary file permissions: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync downloaded temporary file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close downloaded temporary file: %w", err)
}
if err := f.parent.Rename(f.name, destinationName); err != nil {
return fmt.Errorf("install downloaded temporary file: %w", err)
}
f.name = ""
if err := syncOpenedDirectory(f.parent); err != nil {
return fmt.Errorf("sync destination directory after downloaded-file replacement: %w", err)
}
return f.closeParent()
}
// Cleanup removes an uninstalled temporary file. It is safe to call repeatedly.
func (f *DownloadedTempFile) Cleanup() error {
if f == nil || f.parent == nil {
return nil
}
var err error
if f.name != "" {
err = f.parent.Remove(f.name)
if errors.Is(err, os.ErrNotExist) {
err = nil
}
f.name = ""
}
closeErr := f.closeParent()
if err != nil && closeErr != nil {
return errors.Join(err, closeErr)
}
if err != nil {
return err
}
return closeErr
}
func (f *DownloadedTempFile) closeParent() error {
if f.parent == nil {
return nil
}
err := f.parent.Close()
f.parent = nil
return err
}
// DownloadAndInstall obtains remote bytes through a confined sibling temporary
// file and installs them with the caller-selected final mode.
func DownloadAndInstall(destination string, mode os.FileMode, download func(io.Writer) error) error {
temporary, err := DownloadToSiblingTemp(destination, download)
if err != nil {
return err
}
defer func() { _ = temporary.Cleanup() }()
return temporary.Install(filepath.Base(destination), mode)
}
func replaceFileFromReaderConfined(dst string, source io.Reader, options ReplaceFileOptions) (resultErr error) {
parent, name, err := openConfinedParent(dst, false, 0)
if err != nil {
return err
}
defer func() { _ = parent.Close() }()
temporary, tempName, err := createSiblingTemp(parent, name)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
removeTemp := true
defer func() {
if removeTemp {
if err := parent.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary file: %w", err))
}
}
}()
if _, err := io.Copy(temporary, source); err != nil {
return closeTemporaryFileAfterError(temporary, fmt.Errorf("write temp file: %w", err))
}
if err := temporary.Chmod(options.Mode); err != nil {
return closeTemporaryFileAfterError(temporary, fmt.Errorf("set temp file permissions: %w", err))
}
if err := temporary.Sync(); err != nil {
return closeTemporaryFileAfterError(temporary, fmt.Errorf("sync temp file: %w", err))
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if options.BeforeRename != nil {
if err := options.BeforeRename(); err != nil {
return fmt.Errorf("check before file replacement: %w", err)
}
}
if err := parent.Rename(tempName, name); err != nil {
return fmt.Errorf("install temp file: %w", err)
}
removeTemp = false
if err := syncOpenedDirectory(parent); err != nil {
return fmt.Errorf("sync destination directory after replacement: %w", err)
}
return nil
}
func installDownloadedTempFileConfined(tmpPath, dst string, mode os.FileMode) error {
if filepath.Clean(filepath.Dir(tmpPath)) != filepath.Clean(filepath.Dir(dst)) {
source, err := os.Open(tmpPath)
if err != nil {
return fmt.Errorf("open downloaded temporary file: %w", err)
}
err = replaceFileFromReaderConfined(dst, source, ReplaceFileOptions{Mode: mode})
closeErr := source.Close()
if err != nil {
return fmt.Errorf("install downloaded file: %w", err)
}
if closeErr != nil {
return fmt.Errorf("close downloaded temporary file: %w", closeErr)
}
if err := os.Remove(tmpPath); err != nil {
return fmt.Errorf("remove installed temporary file: %w", err)
}
return nil
}
parent, destinationName, err := openConfinedParent(dst, false, 0)
if err != nil {
return err
}
defer func() { _ = parent.Close() }()
temporaryName := filepath.Base(tmpPath)
info, err := parent.Lstat(temporaryName)
if err != nil {
return fmt.Errorf("inspect downloaded temporary file: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("downloaded temporary file is not a regular file")
}
file, err := parent.Open(temporaryName)
if err != nil {
return fmt.Errorf("open downloaded temporary file: %w", err)
}
if err := file.Chmod(mode); err != nil {
_ = file.Close()
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("sync downloaded temp file: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close downloaded temp file: %w", err)
}
if err := parent.Rename(temporaryName, destinationName); err != nil {
return fmt.Errorf("install downloaded file: %w", err)
}
if err := syncOpenedDirectory(parent); err != nil {
return fmt.Errorf("sync destination directory after downloaded-file replacement: %w", err)
}
return nil
}

View File

@@ -21,6 +21,13 @@ func PromoteDirectory(src, dst string) error {
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
return err
}
parent, _, 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)
}
return promoteDirectory(src, dst, renameDirectoryNoReplace)
}

View File

@@ -431,6 +431,29 @@ func TestPromoteDirectoryRejectsDestinationInsideSource(t *testing.T) {
}
}
func TestPromoteDirectoryRejectsSymlinkedDestinationAncestor(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
if err := os.Mkdir(source, 0o755); err != nil {
t.Fatalf("Mkdir(source) error = %v", err)
}
if err := os.WriteFile(filepath.Join(source, "value.txt"), []byte("source"), 0o644); err != nil {
t.Fatalf("WriteFile(source) error = %v", err)
}
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
t.Skipf("Symlink unavailable: %v", err)
}
err := PromoteDirectory(source, filepath.Join(root, "redirect", "bundle"))
if err == nil {
t.Fatal("PromoteDirectory() error = nil, want symlink ancestor rejection")
}
if _, err := os.Stat(filepath.Join(outside, "bundle")); !os.IsNotExist(err) {
t.Fatalf("outside bundle exists: stat err = %v", err)
}
}
func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -32,7 +32,7 @@ type ReplaceFileOptions struct {
// existing destination or directory syncing is unavailable, it returns an
// error rather than claiming an equivalent durability guarantee.
func ReplaceFileAtomic(dst string, data []byte, options ReplaceFileOptions) error {
return replaceFileAtomicWithOperations(dst, data, options, systemAtomicReplacementOperations)
return replaceFileFromReaderConfined(dst, bytes.NewReader(data), options)
}
// WriteFileAtomic creates the destination parent with workspace permissions
@@ -72,11 +72,10 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
}
digest := sha256.New()
err = replaceFileFromReaderWithOperations(
err = replaceFileFromReaderConfined(
dst,
io.TeeReader(in, digest),
ReplaceFileOptions{Mode: perm},
systemAtomicReplacementOperations,
)
if err != nil {
return "", err
@@ -94,7 +93,7 @@ func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
return installDownloadedTempFileWithOperations(tmpPath, dst, perm, systemAtomicReplacementOperations)
return installDownloadedTempFileConfined(tmpPath, dst, perm)
}
type temporaryFile interface {

View File

@@ -115,6 +115,82 @@ func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
}
}
func TestWriteFileAtomicRejectsSymlinkedDestinationAncestor(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
sentinel := filepath.Join(outside, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil {
t.Fatalf("WriteFile(sentinel) error = %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
t.Skipf("Symlink unavailable: %v", err)
}
err := WriteFileAtomic(filepath.Join(root, "redirect", "output.txt"), []byte("new"), 0o640)
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want symlink ancestor rejection")
}
data, err := os.ReadFile(sentinel)
if err != nil {
t.Fatalf("ReadFile(sentinel) error = %v", err)
}
if string(data) != "unchanged" {
t.Fatalf("outside sentinel = %q, want unchanged", data)
}
if _, err := os.Stat(filepath.Join(outside, "output.txt")); !os.IsNotExist(err) {
t.Fatalf("outside output exists: stat err = %v", err)
}
}
func TestWriteFileAtomicReplacesLeafSymlinkWithoutFollowingIt(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
t.Fatalf("WriteFile(outside) error = %v", err)
}
destination := filepath.Join(root, "output.txt")
if err := os.Symlink(outside, destination); err != nil {
t.Skipf("Symlink unavailable: %v", err)
}
if err := WriteFileAtomic(destination, []byte("inside"), 0o640); err != nil {
t.Fatalf("WriteFileAtomic() error = %v", err)
}
outsideData, err := os.ReadFile(outside)
if err != nil {
t.Fatalf("ReadFile(outside) error = %v", err)
}
if string(outsideData) != "outside" {
t.Fatalf("outside file = %q, want unchanged", outsideData)
}
info, err := os.Lstat(destination)
if err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("destination was not replaced with a regular file: info=%v err=%v", info, err)
}
}
func TestDownloadAndInstallRejectsSymlinkedDestinationAncestor(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
t.Skipf("Symlink unavailable: %v", err)
}
err := DownloadAndInstall(filepath.Join(root, "redirect", "output.txt"), 0o640, func(io.Writer) error {
return nil
})
if err == nil {
t.Fatal("DownloadAndInstall() error = nil, want symlink ancestor rejection")
}
entries, err := os.ReadDir(outside)
if err != nil {
t.Fatalf("ReadDir(outside) error = %v", err)
}
if len(entries) != 0 {
t.Fatalf("outside destination received entries: %v", entries)
}
}
func TestReplaceFileAtomicOrdersDurableOperations(t *testing.T) {
events := make([]string, 0, 8)
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}

View File

@@ -3,7 +3,6 @@ package fileops
import (
"fmt"
"os"
"path/filepath"
"strings"
)
@@ -23,35 +22,7 @@ func EnsureWorkspaceDirectory(directory string) error {
return fmt.Errorf("workspace directory is required")
}
directory = filepath.Clean(directory)
missing := make([]string, 0)
for current := directory; ; current = filepath.Dir(current) {
info, err := os.Lstat(current)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("workspace directory %q is not a directory", current)
}
break
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect workspace directory %q: %w", current, err)
}
missing = append(missing, current)
parent := filepath.Dir(current)
if parent == current {
break
}
}
if err := os.MkdirAll(directory, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("create workspace directory %q: %w", directory, err)
}
for index := len(missing) - 1; index >= 0; index-- {
if err := os.Chmod(missing[index], WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", missing[index], err)
}
}
if err := os.Chmod(directory, WorkspaceDirectoryMode); err != nil {
if err := ensureConfinedDirectory(directory, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", directory, err)
}
return nil

View File

@@ -16,7 +16,11 @@ func syncDirectory(path string) error {
}
defer func() { _ = directory.Close() }()
err = directory.Sync()
return syncDirectoryFile(directory, path)
}
func syncDirectoryFile(directory *os.File, path string) error {
err := directory.Sync()
// Some Unix filesystems do not implement directory syncing. Report this
// explicitly so callers do not confuse visible replacement with a durable one.
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {

View File

@@ -4,9 +4,14 @@ package fileops
import (
"fmt"
"os"
"runtime"
)
func syncDirectory(path string) error {
return fmt.Errorf("%w for %q on %s", ErrDirectorySyncUnsupported, path, runtime.GOOS)
}
func syncDirectoryFile(_ *os.File, path string) error {
return syncDirectory(path)
}

View File

@@ -5,6 +5,7 @@ package fileops
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/windows"
)
@@ -39,3 +40,13 @@ func syncDirectory(path string) error {
}
return err
}
func syncDirectoryFile(directory *os.File, path string) error {
err := directory.Sync()
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) ||
errors.Is(err, windows.ERROR_INVALID_HANDLE) ||
errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
return fmt.Errorf("%w for %q: %w", ErrDirectorySyncUnsupported, path, err)
}
return err
}