fix workspace output and cleanup uncertainty
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
//go:build darwin
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadCanonicalRegular opens an absolute canonical path component by component from the root
|
||||
// descriptor. O_NOFOLLOW rejects symlinks at every component, and the open directory descriptors
|
||||
// prevent later parent replacement from redirecting the final open.
|
||||
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
|
||||
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directories := []int{directory}
|
||||
defer func() { closeUnixDescriptors(directories) }()
|
||||
|
||||
for _, component := range components[:len(components)-1] {
|
||||
nextDirectory, err := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directory = nextDirectory
|
||||
directories = append(directories, directory)
|
||||
}
|
||||
|
||||
descriptor, err := unix.Openat(directory, components[len(components)-1], unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
file := os.NewFile(uintptr(descriptor), "thothctl-safeio")
|
||||
if file == nil {
|
||||
unix.Close(descriptor)
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
var before, after unix.Stat_t
|
||||
if err := unix.Fstat(int(file.Fd()), &before); err != nil || before.Nlink > 1 || before.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
contents, err := readBoundedRegularFile(file, maximum)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
// Checking the retained descriptor alone misses a pathname replacement while the
|
||||
// read is in progress. The parent descriptor and final name must still resolve to
|
||||
// the exact open file after reading.
|
||||
if err := unix.Fstat(int(file.Fd()), &after); err != nil || after.Nlink > 1 || after.Mode != before.Mode || after.Ino != before.Ino || after.Dev != before.Dev || after.Size != before.Size {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
var named unix.Stat_t
|
||||
if err := unix.Fstatat(directory, components[len(components)-1], &named, unix.AT_SYMLINK_NOFOLLOW); err != nil || named.Nlink > 1 || named.Mode != before.Mode || named.Ino != before.Ino || named.Dev != before.Dev {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if !recheckUnixParents(components, directories) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func closeUnixDescriptors(descriptors []int) {
|
||||
for _, descriptor := range descriptors {
|
||||
unix.Close(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// Darwin uses a named stage because it has no relinkable O_TMPFILE equivalent.
|
||||
// The caller must provide a trusted parent namespace: same-UID namespace mutation
|
||||
// (including stage hard-link/replacement races and ancestor replacement) is outside
|
||||
// this mode's threat model. Under that precondition renameatx_np(RENAME_EXCL) is the
|
||||
// final fallible no-replace commit and removes the stage atomically.
|
||||
var darwinUnlinkat = unix.Unlinkat
|
||||
var darwinFstatat = unix.Fstatat
|
||||
var darwinCloseStage = func(file *os.File) error { return file.Close() }
|
||||
|
||||
func cleanupDarwinStage(dir int, stage string, staged *unix.Stat_t, closeStage func() error) error {
|
||||
var current unix.Stat_t
|
||||
statErr := darwinFstatat(dir, stage, ¤t, unix.AT_SYMLINK_NOFOLLOW)
|
||||
if statErr == unix.ENOENT {
|
||||
_ = closeStage()
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if statErr != nil || staged == nil || current.Ino != staged.Ino || current.Dev != staged.Dev {
|
||||
_ = closeStage()
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
unlinkErr := darwinUnlinkat(dir, stage, 0)
|
||||
closeErr := closeStage()
|
||||
if unlinkErr == nil {
|
||||
// Successful unlink proves that no named candidate bytes remain; close
|
||||
// errors cannot make the unlinked inode reachable by pathname.
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
var after unix.Stat_t
|
||||
if verifyErr := darwinFstatat(dir, stage, &after, unix.AT_SYMLINK_NOFOLLOW); verifyErr == unix.ENOENT {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = closeErr
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
|
||||
func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil || len(contents) > 16<<20 || mode.Perm() != 0o600 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { _ = unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, openErr := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if openErr != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stage, err := privateStageName()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageFD, err := unix.Openat(dir, stage, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, uint32(mode.Perm()))
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageFile := os.NewFile(uintptr(stageFD), "thothctl-safeio-stage")
|
||||
if stageFile == nil {
|
||||
closeErr := unix.Close(stageFD)
|
||||
unlinkErr := darwinUnlinkat(dir, stage, 0)
|
||||
if closeErr != nil || unlinkErr != nil {
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
closed := false
|
||||
closeStage := func() error {
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
closed = true
|
||||
return darwinCloseStage(stageFile)
|
||||
}
|
||||
defer func() { _ = closeStage() }()
|
||||
var staged unix.Stat_t
|
||||
if err := unix.Fstat(stageFD, &staged); err != nil || staged.Nlink != 1 || staged.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
_ = closeStage()
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
fail := func() error { return cleanupDarwinStage(dir, stage, &staged, closeStage) }
|
||||
if err := stageFile.Chmod(mode); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if n, err := stageFile.Write(contents); err != nil || n != len(contents) {
|
||||
return fail()
|
||||
}
|
||||
if err := stageFile.Sync(); err != nil {
|
||||
return fail()
|
||||
}
|
||||
var after unix.Stat_t
|
||||
if err := unix.Fstat(stageFD, &after); err != nil || after.Nlink != 1 || after.Mode&unix.S_IFMT != unix.S_IFREG || after.Size != int64(len(contents)) {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
// Verify the named stage and its retained handle immediately before commit.
|
||||
var named unix.Stat_t
|
||||
if err := unix.Fstatat(dir, stage, &named, unix.AT_SYMLINK_NOFOLLOW); err != nil || named.Ino != staged.Ino || named.Dev != staged.Dev || named.Nlink != 1 {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.Fstat(stageFD, &after); err != nil || after.Ino != staged.Ino || after.Dev != staged.Dev || after.Nlink != 1 {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.Fsync(dir); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.RenameatxNp(dir, stage, dir, components[len(components)-1], unix.RENAME_EXCL); err != nil {
|
||||
return fail()
|
||||
}
|
||||
// renameatx_np is the final commit. Closing the still-open handle is ignored
|
||||
// after success and never triggers pathname cleanup or a false failure.
|
||||
_ = closeStage()
|
||||
return nil
|
||||
}
|
||||
func privateStageName() (string, error) {
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ".thothctl-candidate-" + hex.EncodeToString(random[:]), nil
|
||||
}
|
||||
|
||||
func validateCanonicalOutputPath(path string) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) < 2 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, err := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dir, components[len(components)-1], &st, unix.AT_SYMLINK_NOFOLLOW); err == nil {
|
||||
return ErrUnsafeFile
|
||||
} else if err != unix.ENOENT {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckUnixParents(components []string, retained []int) bool {
|
||||
if len(retained) != len(components) {
|
||||
return false
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for i, component := range components[:len(components)-1] {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
if unix.Fstat(next, &got) != nil || unix.Fstat(retained[i+1], &want) != nil || got.Ino != want.Ino || got.Dev != want.Dev {
|
||||
unix.Close(next)
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func recheckUnixParentPath(components []string, retained int) bool {
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
return unix.Fstat(dir, &got) == nil && unix.Fstat(retained, &want) == nil && got.Ino == want.Ino && got.Dev == want.Dev
|
||||
}
|
||||
|
||||
func validatePlatformPathSyntax(path string) error { return nil }
|
||||
Reference in New Issue
Block a user