carlos / platform Public

Add durable exclusive private file IO for Linux maintenance clients

Paul Campbell pushed by paul@keymail.dev fe90cebb583d09ff7b06e5b9566e60d21775df43 parent af52d1b
4 files changed, +346 −0
  • internal/privatefile/io_linux.go +114 −0
  • internal/privatefile/io_linux_test.go +201 −0
  • internal/privatefile/io_other.go +6 −0
  • internal/privatefile/privatefile.go +25 −0
diff --git a/internal/privatefile/io_linux.go b/internal/privatefile/io_linux.go
new file mode 100644
index 000000000..206989a3e
--- /dev/null
+++ b/internal/privatefile/io_linux.go
@@ -0,0 +1,114 @@
+//go:build linux
+
+package privatefile
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+// Walk using retained directory descriptors so a renamed ancestor cannot turn
+// a checked path into a symlink traversal.
+func parent(path string) (*os.File, string, error) {
+ if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" {
+ return nil, "", ErrUnavailable
+ }
+ parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
+ fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, "", ErrUnavailable
+ }
+ for _, part := range parts[:len(parts)-1] {
+ next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ unix.Close(fd)
+ if e != nil {
+ return nil, "", ErrUnavailable
+ }
+ fd = next
+ }
+ return os.NewFile(uintptr(fd), "private directory"), parts[len(parts)-1], nil
+}
+func valid(f *os.File, max int) bool {
+ var st unix.Stat_t
+ return unix.Fstat(int(f.Fd()), &st) == nil && st.Mode&unix.S_IFMT == unix.S_IFREG && st.Mode&07777 == 0600 && st.Uid == uint32(os.Geteuid()) && st.Nlink == 1 && st.Size >= 0 && st.Size <= int64(max)
+}
+func read(path string, max int) ([]byte, error) {
+ if max <= 0 || max == int(^uint(0)>>1) {
+ return nil, ErrUnavailable
+ }
+ p, name, e := parent(path)
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ defer p.Close()
+ fd, e := unix.Openat(int(p.Fd()), name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ f := os.NewFile(uintptr(fd), "private input")
+ if !valid(f, max) {
+ f.Close()
+ return nil, ErrUnavailable
+ }
+ b, e := io.ReadAll(io.LimitReader(f, int64(max)+1))
+ ok := valid(f, max)
+ ce := f.Close()
+ if e != nil || ce != nil || !ok || len(b) > max {
+ clear(b)
+ return nil, ErrUnavailable
+ }
+ return b, nil
+}
+
+type durableFile interface {
+ Write([]byte) (int, error)
+ Sync() error
+ Close() error
+}
+
+func finish(f durableFile, p interface{ Sync() error }, b []byte) error {
+ n, e := f.Write(b)
+ if e != nil || n != len(b) {
+ f.Close()
+ return ErrUnavailable
+ }
+ if f.Sync() != nil {
+ f.Close()
+ return ErrUnavailable
+ }
+ if f.Close() != nil {
+ return ErrUnavailable
+ }
+ if p.Sync() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+func create(path string, b []byte, max int) error {
+ if max <= 0 || len(b) > max {
+ return ErrUnavailable
+ }
+ p, name, e := parent(path)
+ if e != nil {
+ return ErrUnavailable
+ }
+ defer p.Close()
+ var st unix.Stat_t
+ if unix.Fstat(int(p.Fd()), &st) != nil || st.Uid != uint32(os.Geteuid()) || st.Mode&0077 != 0 {
+ return ErrUnavailable
+ }
+ fd, e := unix.Openat(int(p.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600)
+ if e != nil {
+ return ErrUnavailable
+ }
+ f := os.NewFile(uintptr(fd), "private output")
+ if !valid(f, max) {
+ f.Close()
+ return ErrUnavailable
+ }
+ return finish(f, p, b)
+}
diff --git a/internal/privatefile/io_linux_test.go b/internal/privatefile/io_linux_test.go
new file mode 100644
index 000000000..275338431
--- /dev/null
+++ b/internal/privatefile/io_linux_test.go
@@ -0,0 +1,201 @@
+//go:build linux
+
+package privatefile
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func privateDir(t *testing.T) string {
+ t.Helper()
+ d := t.TempDir()
+ if e := os.Chmod(d, 0700); e != nil {
+ t.Fatal(e)
+ }
+ return d
+}
+func TestExactAndExclusive(t *testing.T) {
+ p := filepath.Join(privateDir(t), "result")
+ b := bytes.Repeat([]byte{0, 255, 10, 32}, 16384)
+ if e := CreateExclusiveDurable(p, b, 65536); e != nil {
+ t.Fatal(e)
+ }
+ got, e := Read(p, 65536)
+ if e != nil || !bytes.Equal(got, b) {
+ t.Fatal("exact read failed", e)
+ }
+ if CreateExclusiveDurable(p, []byte("replacement"), 65536) != ErrUnavailable {
+ t.Fatal("overwrote")
+ }
+ if _, e = Read(p, 65535); e != ErrUnavailable {
+ t.Fatal("accepted oversize")
+ }
+ if CreateExclusiveDurable(p+"big", append(b, 0), 65536) != ErrUnavailable {
+ t.Fatal("accepted oversize create")
+ }
+}
+func TestUnsafeFiles(t *testing.T) {
+ for _, kind := range []string{"mode", "symlink", "ancestor", "hardlink", "fifo", "directory", "relative", "missing", "parent"} {
+ t.Run(kind, func(t *testing.T) {
+ d := privateDir(t)
+ p := filepath.Join(d, "input")
+ if e := os.WriteFile(p, []byte("private"), 0600); e != nil {
+ t.Fatal(e)
+ }
+ switch kind {
+ case "mode":
+ os.Chmod(p, 0640)
+ case "symlink":
+ q := p + "link"
+ os.Symlink(p, q)
+ p = q
+ case "ancestor":
+ q := d + "link"
+ os.Symlink(d, q)
+ t.Cleanup(func() { os.Remove(q) })
+ p = filepath.Join(q, "input")
+ case "hardlink":
+ os.Link(p, p+"link")
+ case "fifo":
+ os.Remove(p)
+ if e := unix.Mkfifo(p, 0600); e != nil {
+ t.Fatal(e)
+ }
+ case "directory":
+ p = d
+ case "relative":
+ p = "relative"
+ case "missing":
+ p += "missing"
+ case "parent":
+ os.Chmod(d, 0755)
+ if CreateExclusiveDurable(p+"new", nil, 10) != ErrUnavailable {
+ t.Fatal("public parent")
+ }
+ return
+ }
+ if _, e := Read(p, 100); e != ErrUnavailable {
+ t.Fatal("unsafe read accepted", e)
+ }
+ if kind == "missing" {
+ return
+ }
+ if e := CreateExclusiveDurable(p, nil, 100); e != ErrUnavailable {
+ t.Fatal("unsafe create accepted", e)
+ }
+ })
+ }
+}
+func TestConcurrentCreate(t *testing.T) {
+ p := filepath.Join(privateDir(t), "race")
+ var wg sync.WaitGroup
+ var successes atomic.Int32
+ for i := 0; i < 32; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if CreateExclusiveDurable(p, []byte("exact"), 100) == nil {
+ successes.Add(1)
+ }
+ }()
+ }
+ wg.Wait()
+ if successes.Load() != 1 {
+ t.Fatal(successes.Load())
+ }
+}
+
+type failingFile struct {
+ *os.File
+ stage string
+}
+
+func (f failingFile) Write(b []byte) (int, error) {
+ if f.stage == "write" {
+ n, _ := f.File.Write(b[:1])
+ return n, errors.New("private diagnostic")
+ }
+ return f.File.Write(b)
+}
+func (f failingFile) Sync() error {
+ if f.stage == "sync" {
+ return errors.New("private diagnostic")
+ }
+ return f.File.Sync()
+}
+func (f failingFile) Close() error {
+ e := f.File.Close()
+ if f.stage == "close" {
+ return errors.New("private diagnostic")
+ }
+ return e
+}
+
+type failingParent struct{}
+
+func (failingParent) Sync() error { return errors.New("private diagnostic") }
+func TestFailurePreservesFileWithoutCompletionClaim(t *testing.T) {
+ for _, stage := range []string{"write", "sync", "close", "parent"} {
+ t.Run(stage, func(t *testing.T) {
+ d := privateDir(t)
+ p := filepath.Join(d, "interrupted")
+ f, e := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if e != nil {
+ t.Fatal(e)
+ }
+ parent, e := os.Open(d)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer parent.Close()
+ var barrier interface{ Sync() error } = parent
+ if stage == "parent" {
+ barrier = failingParent{}
+ }
+ if e = finish(failingFile{f, stage}, barrier, []byte("private")); e != ErrUnavailable {
+ t.Fatal(e)
+ }
+ if _, e = os.Stat(p); e != nil {
+ t.Fatal("failed file removed")
+ }
+ if CreateExclusiveDurable(p, []byte("replacement"), 100) != ErrUnavailable {
+ t.Fatal("failed file overwritten")
+ }
+ // A read can succeed even after failed sync; it never proves durability.
+ if _, e = Read(p, 100); e != nil {
+ t.Fatal(e)
+ }
+ })
+ }
+}
+
+func TestCreateThroughSymlinkAndBounds(t *testing.T) {
+ d := privateDir(t)
+ alias := d + "-alias"
+ if e := os.Symlink(d, alias); e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() { os.Remove(alias) })
+ if CreateExclusiveDurable(filepath.Join(alias, "new"), []byte("secret"), 10) != ErrUnavailable {
+ t.Fatal("ancestor symlink followed")
+ }
+ if _, e := os.Stat(filepath.Join(d, "new")); !os.IsNotExist(e) {
+ t.Fatal("created through alias")
+ }
+ for _, max := range []int{0, -1} {
+ if CreateExclusiveDurable(filepath.Join(d, "new"), nil, max) != ErrUnavailable {
+ t.Fatal("invalid bound")
+ }
+ if _, e := Read(filepath.Join(d, "new"), max); e != ErrUnavailable {
+ t.Fatal("invalid read bound")
+ }
+ }
+}
diff --git a/internal/privatefile/io_other.go b/internal/privatefile/io_other.go
new file mode 100644
index 000000000..a07694fd8
--- /dev/null
+++ b/internal/privatefile/io_other.go
@@ -0,0 +1,6 @@
+//go:build !linux
+
+package privatefile
+
+func read(string, int) ([]byte, error) { return nil, ErrUnsupported }
+func create(string, []byte, int) error { return ErrUnsupported }
diff --git a/internal/privatefile/privatefile.go b/internal/privatefile/privatefile.go
new file mode 100644
index 000000000..048f7317e
--- /dev/null
+++ b/internal/privatefile/privatefile.go
@@ -0,0 +1,25 @@
+// Package privatefile handles bounded private files. Runtime support is Linux
+// only; other platforms refuse operations until their privacy guarantees exist.
+package privatefile
+
+import "errors"
+
+var ErrUnavailable = errors.New("private file unavailable")
+var ErrUnsupported = errors.New("private files unsupported on this platform")
+
+// Read returns exact bytes from an owned, single-link, regular 0600 file.
+// Paths must be absolute and already clean. It rejects symlinks in every path component. The caller validates payload
+// syntax and completeness: Read cannot distinguish an interrupted write from
+// intentionally short content. max must be positive.
+func Read(path string, max int) ([]byte, error) { return read(path, max) }
+
+// CreateExclusiveDurable creates a new 0600 file and checks file and parent
+// synchronization before returning success. The parent must already exist and
+// be owned and private. The path must be absolute and already clean. Failures preserve any created file, which prevents
+// retry from overwriting it. The caller must retain its recovery material and
+// must not treat a later Read as proof that this operation completed. A future
+// resume caller needs its own checked durability barrier after validating the
+// saved payload; reopening a file does not repair a failed synchronization.
+func CreateExclusiveDurable(path string, data []byte, max int) error {
+ return create(path, data, max)
+}