Add durable exclusive private file IO for Linux maintenance clients
4 files changed,
+346
−0
internal/privatefile/io_linux.go+114 −0internal/privatefile/io_linux_test.go+201 −0internal/privatefile/io_other.go+6 −0internal/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) |
| +} |