Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/observability/rename_noreplace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package observability

import (
"os"

"github.com/Twigpine/zero/internal/pathjail"
)

// renameNoReplace publishes a crash report without replacing an existing one.
// The implementation lives in pathjail because the file tools need the same
// atomic no-replace guarantee.
func renameNoReplace(root *os.Root, oldname, newname string) error {
return pathjail.RenameNoReplace(root, oldname, newname)
}
19 changes: 0 additions & 19 deletions internal/observability/rename_noreplace_darwin.go

This file was deleted.

19 changes: 0 additions & 19 deletions internal/observability/rename_noreplace_linux.go

This file was deleted.

14 changes: 12 additions & 2 deletions internal/pathjail/pathjail.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
// trusts (a workspace the operator opened, say, which may legitimately sit under
// a link). Everything BELOW it is resolved against the returned handle and
// cannot escape.
func Open(root, dir string) (*os.Root, string, error) {

Check failure on line 52 in internal/pathjail/pathjail.go

View workflow job for this annotation

GitHub Actions / Code Quality & Lint

unreachable func: Open
// Only a genuinely EMPTY value is treated as missing; every other spelling is
// used verbatim. Testing TrimSpace(...) == "" instead would retarget the very
// paths this guards: " " is a legal directory name on unix, so trimming would
Expand Down Expand Up @@ -98,7 +98,7 @@
// ModeIrregular is tested alongside ModeSymlink deliberately: that is what a
// Windows junction reports, and a guard checking only ModeSymlink lets one
// straight through.
func RefuseReparse(handle *os.Root, relative string) error {

Check failure on line 101 in internal/pathjail/pathjail.go

View workflow job for this annotation

GitHub Actions / Code Quality & Lint

unreachable func: RefuseReparse
// A trailing separator makes os.Root.Lstat resolve the terminal component on
// Windows (doInRootAlwaysResolveTerminalSlash), so a junction named with a
// trailing slash would be stat'd as its target and wave through as "not a
Expand Down Expand Up @@ -127,7 +127,7 @@
// trimTrailingSeparators drops the path separators at the end of a name, using
// the ACTIVE platform's definition of one: `/` and `\` on Windows, `/` only on
// unix, where a backslash is a legal character in a filename.
func trimTrailingSeparators(path string) string {

Check failure on line 130 in internal/pathjail/pathjail.go

View workflow job for this annotation

GitHub Actions / Code Quality & Lint

unreachable func: trimTrailingSeparators
end := len(path)
for end > 0 && os.IsPathSeparator(path[end-1]) {
end--
Expand All @@ -142,12 +142,22 @@
var randomBytes = rand.Read

// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
// relative to handle, and returns it with its relative path.
// relative to handle, and returns it with its relative path. The file is
// created 0o600 so bytes staged in it are not readable before publication.
//
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
// between the check and the create is the same race this package exists to
// close, and O_EXCL makes the kernel refuse it rather than us.
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
return CreateTempMode(handle, dir, prefix, suffix, 0o600)
}

// CreateTempMode is CreateTemp with an explicit create mode. The mode is
// passed to the kernel's exclusive create, so the process umask still applies;
// callers that must publish the permissions a normal create would have been
// granted should read the returned file's mode rather than assigning perm
// later, because Chmod does not consult the umask.
func CreateTempMode(handle *os.Root, dir, prefix, suffix string, perm os.FileMode) (*os.File, string, error) {
// prefix and suffix are NAME fragments, not path fragments. A separator or a
// "."/".." in either would relocate the file out of dir — a prefix of
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
Expand All @@ -164,7 +174,7 @@
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
}
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm)
if err == nil {
return file, relative, nil
}
Expand Down
28 changes: 28 additions & 0 deletions internal/pathjail/rename_noreplace.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package pathjail

import (
"fmt"
"os"
"path/filepath"
)

// RenameNoReplace renames oldname to newname within the directory bound by
// root, and refuses to replace an existing newname with the same atomic
// guarantee the kernel gives a create's exclusive open. A caller that loses
// the race receives fs.ErrExist and nothing at newname changes.
//
// Both names must be single path components: they are resolved relative to
// root's directory handle, so a separator (or an ancestor link) must not be
// able to redirect the syscall outside the checked root. Callers with a path
// in a subdirectory open that directory with Root.OpenRoot first.
//
// Platforms whose kernel cannot publish without replacing fall back to
// ErrUnsupported, which lets the caller use a no-replace hard link instead.
func RenameNoReplace(root *os.Root, oldname, newname string) error {
for _, name := range []string{oldname, newname} {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name {
return fmt.Errorf("%w: rename name %q must be a single path component", ErrEscapes, name)
}
}
return renameNoReplace(root, oldname, newname)
}
22 changes: 22 additions & 0 deletions internal/pathjail/rename_noreplace_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build darwin

package pathjail

import (
"fmt"
"os"

"golang.org/x/sys/unix"
)

func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open directory for rename: %w", err)
}
defer directory.Close()
if err := unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL); err != nil {
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
}
return nil
}
22 changes: 22 additions & 0 deletions internal/pathjail/rename_noreplace_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build linux

package pathjail

import (
"fmt"
"os"

"golang.org/x/sys/unix"
)

func renameNoReplace(root *os.Root, oldname, newname string) error {
directory, err := root.Open(".")
if err != nil {
return fmt.Errorf("open directory for rename: %w", err)
}
defer directory.Close()
if err := unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE); err != nil {
return fmt.Errorf("rename %s to %s: %w", oldname, newname, err)
}
return nil
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//go:build !darwin && !linux && !windows

package observability
package pathjail

import (
"errors"
Expand Down
79 changes: 79 additions & 0 deletions internal/pathjail/rename_noreplace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package pathjail

import (
"errors"
"os"
"path/filepath"
"testing"
)

func openRenameRoot(t *testing.T) (*os.Root, string) {
t.Helper()
base := t.TempDir()
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = root.Close() })
return root, base
}

// A CREATE'S NAME MUST NOT BE LOST TO A RACE. The no-replace rename reports the
// collision as fs.ErrExist and leaves both files exactly as they were, so the
// caller can refuse the create instead of replacing the other writer.
func TestRenameNoReplaceRefusesAnExistingDestination(t *testing.T) {
root, base := openRenameRoot(t)
if err := os.WriteFile(filepath.Join(base, "source"), []byte("source\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "target"), []byte("target\n"), 0o644); err != nil {
t.Fatal(err)
}

if err := RenameNoReplace(root, "source", "target"); !errors.Is(err, os.ErrExist) {
t.Fatalf("RenameNoReplace over an existing name = %v, want os.ErrExist", err)
}
for name, want := range map[string]string{"source": "source\n", "target": "target\n"} {
got, err := os.ReadFile(filepath.Join(base, name))
if err != nil || string(got) != want {
t.Fatalf("%s = %q, err=%v; a refused rename must not move either file", name, got, err)
}
}
}

func TestRenameNoReplacePublishesAnAbsentDestination(t *testing.T) {
root, base := openRenameRoot(t)
if err := os.WriteFile(filepath.Join(base, "source"), []byte("whole\n"), 0o644); err != nil {
t.Fatal(err)
}

if err := RenameNoReplace(root, "source", "target"); err != nil {
t.Fatalf("RenameNoReplace = %v", err)
}
if got, err := os.ReadFile(filepath.Join(base, "target")); err != nil || string(got) != "whole\n" {
t.Fatalf("target = %q, err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(base, "source")); !os.IsNotExist(err) {
t.Fatalf("source survived a successful rename: %v", err)
}
}

// The syscall is given a directory handle and bare names. Allowing a
// separator here would let an ancestor component resolve outside the directory
// the caller checked, so it is refused before the syscall.
func TestRenameNoReplaceRejectsPathComponents(t *testing.T) {
root, _ := openRenameRoot(t)
for _, tc := range []struct{ oldname, newname string }{
{"", "target"},
{"source", ""},
{".", "target"},
{"source", ".."},
{"dir/source", "target"},
{"source", "dir/target"},
{filepath.Join("dir", "source"), "target"},
} {
if err := RenameNoReplace(root, tc.oldname, tc.newname); !errors.Is(err, ErrEscapes) {
t.Errorf("RenameNoReplace(%q, %q) = %v, want ErrEscapes", tc.oldname, tc.newname, err)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//go:build windows

package observability
package pathjail

import (
"errors"
Expand Down
Loading
Loading