mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b3ca3599c | ||
|
|
6e08e9c259 | ||
|
|
67d1e37580 | ||
|
|
b239788334 | ||
|
|
230a129630 | ||
|
|
024ba0ee89 | ||
|
|
8ff542bb8f | ||
|
|
3b81dc0f1d | ||
|
|
8400f9086e | ||
|
|
48610b4aa5 | ||
|
|
e0168d4a72 | ||
|
|
7758ec5934 | ||
|
|
8c23620b88 | ||
|
|
4df239b32d | ||
|
|
ab02818d93 | ||
|
|
d5c6f85c56 | ||
|
|
548765ef4f | ||
|
|
ada465979d |
No files matched your search
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
+31
-2
@@ -30,15 +30,35 @@ var processAlive = osProcessAlive
|
||||
// only to enrich a contention error with the current holder's PID; the kernel
|
||||
// lock, not PID metadata, is authoritative.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLock(path)
|
||||
}, func() (int, error) {
|
||||
return readPidFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
|
||||
}, func() (int, error) {
|
||||
return readPidFileRoot(root, name)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockWith(
|
||||
isAlive func(pid int) bool,
|
||||
acquire func() (*lockutil.FileLock, error),
|
||||
readPID func() (int, error),
|
||||
) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
lock, err := lockutil.TryAcquireFileLock(path)
|
||||
lock, err := acquire()
|
||||
if err != nil {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
pid, perr := readPID()
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
@@ -62,6 +82,15 @@ func (l *fileLock) release() error {
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func readPidFileRoot(root *os.Root, name string) (int, error) {
|
||||
data, err := root.ReadFile(name)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func parsePidFile(data []byte, err error) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+162
-7
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -23,6 +24,23 @@ type Server struct {
|
||||
listener net.Listener
|
||||
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
|
||||
lock *fileLock
|
||||
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
|
||||
// child is addressed relative to this capability; runtimeDir is used only to
|
||||
// verify the unavoidable AF_UNIX pathname bind still names the same object.
|
||||
runtimeRoot *os.Root
|
||||
runtimeDir string
|
||||
// socketRoot captures the directory that actually received the pathname
|
||||
// bind. It can differ from runtimeRoot only when the configured directory is
|
||||
// exchanged in the final pre-bind window; retaining it makes rollback target
|
||||
// the socket that was really created rather than re-resolving the pathname.
|
||||
socketRoot *os.Root
|
||||
socketName string
|
||||
// statusRoot binds status publication and shutdown cleanup to the same
|
||||
// directory object. statusCommitted is set only after this server publishes
|
||||
// its document, so a failed startup never removes a previous daemon's status.
|
||||
statusRoot *os.Root
|
||||
statusName string
|
||||
statusCommitted bool
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -41,6 +59,14 @@ type ServerOptions struct {
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
|
||||
// for the status-file commit boundary. nil selects production behavior.
|
||||
beforeStatusReplace func()
|
||||
replaceStatusFile func(root *os.Root, src, dst string) error
|
||||
syncStatusParent func(root *os.Root) error
|
||||
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
|
||||
beforeSocketBind func() // test hook before the final root verification
|
||||
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
@@ -80,11 +106,41 @@ func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if isDefault {
|
||||
s.runtimeRoot = defaultRoot
|
||||
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
|
||||
s.statusRoot = defaultRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
if s.opts.afterRuntimeRootOpen != nil {
|
||||
s.opts.afterRuntimeRootOpen()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
|
||||
return err
|
||||
}
|
||||
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open status directory: %w", err)
|
||||
}
|
||||
s.statusRoot = statusRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
}
|
||||
var lock *fileLock
|
||||
if s.runtimeRoot != nil {
|
||||
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
|
||||
} else {
|
||||
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
}
|
||||
if err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
@@ -92,7 +148,22 @@ func (s *Server) Serve() error {
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("daemon: remove stale control socket: %w", err)
|
||||
}
|
||||
if s.opts.beforeSocketBind != nil {
|
||||
s.opts.beforeSocketBind()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.afterSocketPreflight != nil {
|
||||
s.opts.afterSocketPreflight()
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
@@ -101,6 +172,22 @@ func (s *Server) Serve() error {
|
||||
s.mu.Lock()
|
||||
s.listener = listener
|
||||
s.mu.Unlock()
|
||||
if s.runtimeRoot != nil {
|
||||
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("daemon: capture bound control socket: %w", err)
|
||||
}
|
||||
s.socketRoot = boundRoot
|
||||
s.socketName = boundName
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
|
||||
if err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
|
||||
}
|
||||
}
|
||||
// If Shutdown already fired during the bind window, close now and bail so a
|
||||
// shutdown requested at startup is never lost (the accept loop would otherwise
|
||||
// block forever waiting for a connection that never comes) (D4).
|
||||
@@ -111,7 +198,7 @@ func (s *Server) Serve() error {
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
if err := s.hardenSocket(); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
@@ -190,11 +277,56 @@ func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
if s.statusRoot != nil {
|
||||
if s.statusCommitted {
|
||||
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove status file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
s.closeRuntimeRoots()
|
||||
}
|
||||
|
||||
func (s *Server) hardenSocket() error {
|
||||
if s.runtimeRoot != nil {
|
||||
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
|
||||
}
|
||||
return hardenSocketFile(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
func (s *Server) closeRuntimeRoots() {
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Close(); err != nil {
|
||||
s.logf("daemon: close bound socket directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.socketRoot = nil
|
||||
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
|
||||
if err := s.statusRoot.Close(); err != nil {
|
||||
s.logf("daemon: close status directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.statusRoot = nil
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Close(); err != nil {
|
||||
s.logf("daemon: close runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.runtimeRoot = nil
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
@@ -208,7 +340,30 @@ func (s *Server) writeStatusFile() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
root := s.statusRoot
|
||||
ownedRoot := false
|
||||
if root == nil {
|
||||
var err error
|
||||
root, err = openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
ownedRoot = true
|
||||
}
|
||||
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
|
||||
if ownedRoot {
|
||||
if closeErr := root.Close(); closeErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
|
||||
}
|
||||
}
|
||||
if committed {
|
||||
s.statusCommitted = true
|
||||
}
|
||||
if err != nil {
|
||||
if committed {
|
||||
s.logf("daemon: status file publication committed with warning: %v", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,20 +2,201 @@ package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
)
|
||||
|
||||
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix permission compatibility regression")
|
||||
}
|
||||
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
srv.Shutdown()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o755 {
|
||||
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-root-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "bound-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.beforeSocketBind = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
|
||||
}
|
||||
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "trusted-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.afterSocketPreflight = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime retained stale socket: %v", err)
|
||||
}
|
||||
listener, err := net.Listen("unix", paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
|
||||
}
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows DACL hardening has platform-specific coverage")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, logPath, err := OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenRuntimeLog: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logPath != filepath.Join(dir, "daemon.log") {
|
||||
t.Fatalf("log path = %q", logPath)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
return newTestServerWithPaths(t, launcher, paths), paths
|
||||
}
|
||||
|
||||
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
|
||||
t.Helper()
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
@@ -28,7 +209,7 @@ func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
return srv
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
@@ -136,6 +317,153 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
dir, err := os.MkdirTemp("", "zero-daemon-status-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
|
||||
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
|
||||
return errors.New("injected replacement failure")
|
||||
}
|
||||
|
||||
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
|
||||
t.Fatal("Serve succeeded despite injected status replacement failure")
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("previous status was removed after failed publication: %v", err)
|
||||
}
|
||||
if string(got) != string(previous) {
|
||||
t.Fatalf("previous status changed after failed publication: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not permit renaming the directory containing the live socket")
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(parent) })
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
srv.opts.beforeStatusReplace = func() {
|
||||
if err := os.Rename(dir, movedDir); err != nil {
|
||||
t.Fatalf("move status directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, filepath.Join(movedDir, "d.status"))
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("bound status survived cleanup: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
|
||||
}
|
||||
if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute status changed during cleanup: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
|
||||
home, err := os.MkdirTemp("", "zero-home-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
|
||||
if _, err := observability.WriteCrashReport(
|
||||
observability.DefaultCrashDir(),
|
||||
"cli",
|
||||
"boom",
|
||||
[]byte("stack"),
|
||||
time.Now(),
|
||||
); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
|
||||
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
|
||||
}
|
||||
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
|
||||
deadline := time.NewTimer(3 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(paths.Status); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
t.Fatalf("Serve returned before publishing status: %v", err)
|
||||
case <-deadline.C:
|
||||
t.Fatal("daemon did not publish its default status file")
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
|
||||
+179
-4
@@ -1,9 +1,12 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
@@ -12,10 +15,182 @@ import (
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
func secureCustomRuntimeParents(paths Paths) error {
|
||||
parents := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "socket", path: filepath.Dir(paths.Socket)},
|
||||
{name: "lock", path: filepath.Dir(paths.Lock)},
|
||||
{name: "status", path: filepath.Dir(paths.Status)},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(parents))
|
||||
for _, parent := range parents {
|
||||
absolute, err := filepath.Abs(parent.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: resolve %s directory: %w", parent.name, err)
|
||||
}
|
||||
if _, ok := seen[absolute]; ok {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = struct{}{}
|
||||
if err := os.MkdirAll(absolute, 0o700); err != nil {
|
||||
return fmt.Errorf("daemon: create %s directory: %w", parent.name, err)
|
||||
}
|
||||
root, err := os.OpenRoot(absolute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open custom %s directory: %w", parent.name, err)
|
||||
}
|
||||
validateErr := validateStatusRoot(root)
|
||||
closeErr := root.Close()
|
||||
if validateErr != nil {
|
||||
return fmt.Errorf("daemon: validate custom %s directory: %w", parent.name, validateErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("daemon: close custom %s directory: %w", parent.name, closeErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenRuntimeLog secures the default runtime root before acquiring the
|
||||
// detached daemon's log descriptor. Custom endpoint layouts remain supported,
|
||||
// but their existing parent permissions are caller-owned and left untouched.
|
||||
func OpenRuntimeLog(paths Paths) (*os.File, string, error) {
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
root, isDefault, err := openDefaultRuntimeRoot(paths)
|
||||
if err != nil {
|
||||
return nil, logPath, err
|
||||
}
|
||||
if isDefault {
|
||||
defer root.Close()
|
||||
file, err := openRootAppendRegular(root, filepath.Base(logPath))
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(logPath)
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: create custom log directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(logPath)
|
||||
if err == nil && !info.Mode().IsRegular() {
|
||||
return nil, logPath, fmt.Errorf("daemon: custom runtime log is not a regular file")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, logPath, fmt.Errorf("daemon: inspect custom runtime log: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open custom runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
func openDefaultRuntimeRoot(paths Paths) (*os.Root, bool, error) {
|
||||
defaults, err := DefaultPaths()
|
||||
if err != nil {
|
||||
// A caller-supplied layout remains usable even when the process has no
|
||||
// resolvable home directory. Production default paths can only reach this
|
||||
// helper after DefaultPaths has already succeeded.
|
||||
return nil, false, nil
|
||||
}
|
||||
matches, err := samePaths(paths, defaults)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !matches {
|
||||
return nil, false, nil
|
||||
}
|
||||
root, err := privatedir.Open(filepath.Dir(defaults.Socket))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("daemon: secure default runtime directory: %w", err)
|
||||
}
|
||||
return root, true, nil
|
||||
}
|
||||
|
||||
func samePaths(left, right Paths) (bool, error) {
|
||||
pairs := [][2]string{
|
||||
{left.Socket, right.Socket},
|
||||
{left.Lock, right.Lock},
|
||||
{left.Status, right.Status},
|
||||
}
|
||||
for _, pair := range pairs {
|
||||
leftAbs, err := filepath.Abs(pair[0])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve runtime path: %w", err)
|
||||
}
|
||||
rightAbs, err := filepath.Abs(pair[1])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve default runtime path: %w", err)
|
||||
}
|
||||
if filepath.Clean(leftAbs) != filepath.Clean(rightAbs) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func runtimeRootStillNamesPath(root *os.Root, path string) error {
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect bound runtime directory: %w", err)
|
||||
}
|
||||
current, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect runtime directory entry: %w", err)
|
||||
}
|
||||
if current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
|
||||
return fmt.Errorf("daemon: default runtime directory changed after it was secured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureBoundSocketRoot(path string) (*os.Root, string, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open bound socket directory: %w", err)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
info, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("inspect bound socket: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("bound socket path is not a socket")
|
||||
}
|
||||
return root, name, nil
|
||||
}
|
||||
|
||||
func openRootAppendRegular(root *os.Root, name string) (*os.File, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("runtime log is not a regular file")
|
||||
}
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !opened.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("runtime log changed while opening")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
|
||||
@@ -10,3 +10,7 @@ import "os"
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
|
||||
func hardenSocketFileRoot(root *os.Root, name string) error {
|
||||
return root.Chmod(name, 0o600)
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
|
||||
func hardenSocketFileRoot(*os.Root, string) error { return nil }
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("status directory ownership metadata is unavailable")
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
|
||||
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
|
||||
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
|
||||
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
|
||||
err := checkStatusDirOwner(nil, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "not the current user") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
type statusDirOwnerTestInfo struct {
|
||||
sys any
|
||||
}
|
||||
|
||||
func (statusDirOwnerTestInfo) Name() string { return "." }
|
||||
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
|
||||
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
|
||||
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (statusDirOwnerTestInfo) IsDir() bool { return true }
|
||||
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
|
||||
windows.GENERIC_ALL |
|
||||
windows.GENERIC_WRITE |
|
||||
windows.DELETE |
|
||||
windows.WRITE_DAC |
|
||||
windows.WRITE_OWNER |
|
||||
windows.FILE_WRITE_DATA |
|
||||
windows.FILE_APPEND_DATA |
|
||||
windows.FILE_WRITE_ATTRIBUTES |
|
||||
windows.FILE_WRITE_EA |
|
||||
0x40, // FILE_DELETE_CHILD
|
||||
)
|
||||
|
||||
// checkStatusDirOwner validates ownership and write access through a handle
|
||||
// opened beneath root. Path-based ACL inspection would recreate the ancestor
|
||||
// swap race that Root is intended to close.
|
||||
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open status directory for access validation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access status directory handle: %w", err)
|
||||
}
|
||||
var descriptor *windows.SECURITY_DESCRIPTOR
|
||||
var queryErr error
|
||||
if err := raw.Control(func(handle uintptr) {
|
||||
descriptor, queryErr = windows.GetSecurityInfo(
|
||||
windows.Handle(handle),
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("inspect status directory access: %w", err)
|
||||
}
|
||||
if queryErr != nil {
|
||||
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
|
||||
}
|
||||
if descriptor == nil {
|
||||
return fmt.Errorf("status directory security descriptor is unavailable")
|
||||
}
|
||||
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows user: %w", err)
|
||||
}
|
||||
tokenOwner, err := currentWindowsTokenOwner(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows token owner: %w", err)
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory owner: %w", err)
|
||||
}
|
||||
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
|
||||
return fmt.Errorf("status directory is not owned by the current Windows token")
|
||||
}
|
||||
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("status directory has an unrestricted Windows DACL")
|
||||
}
|
||||
for index := uint16(0); index < dacl.AceCount; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
|
||||
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
|
||||
}
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_DENIED_ACE_TYPE:
|
||||
continue
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE:
|
||||
default:
|
||||
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
|
||||
}
|
||||
if ace.Mask&statusDirectoryWriteAccess == 0 {
|
||||
continue
|
||||
}
|
||||
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
|
||||
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type statusDirectoryTokenOwner struct {
|
||||
owner *windows.SID
|
||||
}
|
||||
|
||||
func currentWindowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := (*statusDirectoryTokenOwner)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
|
||||
return trustee != nil && (trustee.Equals(user) ||
|
||||
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.SecurityDescriptorFromString(
|
||||
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
dacl,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(worldSID)
|
||||
defer pinner.Unpin()
|
||||
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
|
||||
},
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
broadDACL,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = checkStatusDirOwner(root, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
const (
|
||||
statusTempPrefix = ".daemon-status-"
|
||||
statusTempPattern = statusTempPrefix + "*"
|
||||
)
|
||||
|
||||
func openStatusRoot(path string) (*os.Root, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open status directory: %w", err)
|
||||
}
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
_ = root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// writeStatusFileAtomicallyRoot publishes through a caller-owned, already
|
||||
// bound directory root. The returned boolean crosses the commit boundary: once
|
||||
// true, err is a durability/cleanup warning and the complete document is live.
|
||||
func writeStatusFileAtomicallyRoot(
|
||||
root *os.Root,
|
||||
statusName string,
|
||||
data []byte,
|
||||
perm os.FileMode,
|
||||
beforeReplace func(),
|
||||
replace func(root *os.Root, src, dst string) error,
|
||||
syncParent func(root *os.Root) error,
|
||||
) (committed bool, returnErr error) {
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
temp, tempName, err := createStatusTemp(root, perm)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
if err := temp.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close temporary status file during cleanup: %w", err))
|
||||
}
|
||||
closed = true
|
||||
}
|
||||
if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary status file: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
return false, fmt.Errorf("set temporary status file permissions: %w", err)
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
return false, fmt.Errorf("write temporary status file: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return false, fmt.Errorf("sync temporary status file: %w", err)
|
||||
}
|
||||
closeErr := temp.Close()
|
||||
closed = true
|
||||
if closeErr != nil {
|
||||
return false, fmt.Errorf("close temporary status file: %w", closeErr)
|
||||
}
|
||||
if beforeReplace != nil {
|
||||
beforeReplace()
|
||||
}
|
||||
if err := prepareStatusReplacement(root, tempName, statusName); err != nil {
|
||||
return false, fmt.Errorf("prepare status replacement: %w", err)
|
||||
}
|
||||
|
||||
rename := root.Rename
|
||||
if replace != nil {
|
||||
rename = func(src, dst string) error { return replace(root, src, dst) }
|
||||
}
|
||||
// On Windows an existing reader that omitted FILE_SHARE_DELETE can block
|
||||
// this replacement even though an in-place write would succeed. Keep the
|
||||
// atomic publication boundary: RenameWithRetry bounds transient sharing
|
||||
// failures to ten short attempts, and a persistent failure leaves the old
|
||||
// status document intact instead of exposing a partially written one.
|
||||
var committedWarning error
|
||||
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
|
||||
var committedReplacement *fsutil.CommittedReplacementCleanupError
|
||||
if !errors.As(err, &committedReplacement) {
|
||||
return false, fmt.Errorf("replace status file: %w", err)
|
||||
}
|
||||
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if syncParent == nil {
|
||||
syncParent = syncStatusRoot
|
||||
}
|
||||
if err := syncParent(root); err != nil {
|
||||
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
|
||||
}
|
||||
if committedWarning != nil {
|
||||
return committed, committedWarning
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func validateStatusRoot(root *os.Root) error {
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect status directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("status directory is not a directory")
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o022 != 0 {
|
||||
return fmt.Errorf("status directory permissions are %04o, want no group/other write access", info.Mode().Perm())
|
||||
}
|
||||
if err := checkStatusDirOwner(root, info); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) {
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return nil, "", fmt.Errorf("generate temporary status file name: %w", err)
|
||||
}
|
||||
name := statusTempPrefix + hex.EncodeToString(suffix[:])
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: %w", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: exhausted unique names")
|
||||
}
|
||||
|
||||
// syncStatusRoot makes the replacement directory entry durable through the
|
||||
// same bound root used for creation and replacement. Windows directory sync is
|
||||
// best-effort because Go cannot fsync a directory there.
|
||||
func syncStatusRoot(root *os.Root) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
syncErr := directory.Sync()
|
||||
closeErr := directory.Close()
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := []byte(`{"pid":7,"socket":"old.sock","version":1,"startedAt":"2026-08-01T00:00:00Z"}`)
|
||||
if err := os.WriteFile(path, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replaceErr := errors.New("injected replacement failure")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
staged, err := root.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read staged status: %v", err)
|
||||
}
|
||||
var decoded StatusFile
|
||||
if err := json.Unmarshal(staged, &decoded); err != nil {
|
||||
t.Fatalf("staged status is incomplete JSON: %v", err)
|
||||
}
|
||||
current, err := root.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read previous status at commit boundary: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("previous status changed before commit: %q", current)
|
||||
}
|
||||
return replaceErr
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := server.writeStatusFile()
|
||||
if !errors.Is(err, replaceErr) {
|
||||
t.Fatalf("writeStatusFile error = %v, want injected replacement failure", err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read preserved status: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("status after failed publication = %q, want previous document", current)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startedAt := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
parentSynced := false
|
||||
server := &Server{
|
||||
startedAt: startedAt,
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 3,
|
||||
syncStatusParent: func(root *os.Root) error {
|
||||
if _, err := root.Stat("."); err != nil {
|
||||
t.Fatalf("stat bound status parent: %v", err)
|
||||
}
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("published status is invalid JSON: %v", err)
|
||||
}
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 3 || !status.StartedAt.Equal(startedAt) {
|
||||
t.Fatalf("published status = %+v", status)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status parent directory was not synced")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("status mode = %04o, want 0600", got)
|
||||
}
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := StatusFile{
|
||||
PID: 7,
|
||||
Socket: filepath.Join(dir, "old.sock"),
|
||||
Version: 1,
|
||||
StartedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
previousData, err := json.Marshal(previous)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, previousData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replacementReady := make(chan struct{})
|
||||
publish := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
close(replacementReady)
|
||||
<-publish
|
||||
return fsutil.RenameWithRetry(src, dst, root.Rename)
|
||||
},
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
writeDone <- server.writeStatusFile()
|
||||
}()
|
||||
|
||||
<-replacementReady
|
||||
for range 100 {
|
||||
status := readStatusDocument(t, path)
|
||||
if status != previous {
|
||||
t.Fatalf("status before commit = %+v, want previous document %+v", status, previous)
|
||||
}
|
||||
}
|
||||
close(publish)
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
|
||||
status := readStatusDocument(t, path)
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 2 || !status.StartedAt.Equal(server.startedAt) {
|
||||
t.Fatalf("status after commit = %+v", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncErr := errors.New("injected directory sync failure")
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 4,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
syncStatusParent: func(*os.Root) error { return syncErr },
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 4 {
|
||||
t.Fatalf("published status = %+v, want version 4", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) {
|
||||
t.Fatalf("logs = %q, want directory sync warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupErr := errors.New("injected backup cleanup failure")
|
||||
parentSynced := false
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 5,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
return &fsutil.CommittedReplacementCleanupError{
|
||||
BackupPath: filepath.Join(dir, ".zero-replace-backup"),
|
||||
Cause: cleanupErr,
|
||||
}
|
||||
},
|
||||
syncStatusParent: func(*os.Root) error {
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status directory was not synced after committed replacement warning")
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 5 {
|
||||
t.Fatalf("published status = %+v, want version 5", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], cleanupErr.Error()) {
|
||||
t.Fatalf("logs = %q, want committed cleanup warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedTempCleanupWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempName string
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
tempName = src
|
||||
if err := root.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatalf("recreate staged path as directory: %v", err)
|
||||
}
|
||||
keep, err := root.OpenFile(filepath.Join(src, "keep"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("make staged directory non-empty: %v", err)
|
||||
}
|
||||
if err := keep.Close(); err != nil {
|
||||
t.Fatalf("close staged directory marker: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("published status = %+v, want version 6", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], "remove temporary status file") {
|
||||
t.Fatalf("logs = %q, want temporary cleanup warning", logs)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName, "keep")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
var swapErr error
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
beforeStatusReplace: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
t.Fatalf("move bound status directory: %v", swapErr)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute status directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("unexpected directory-swap error: %v", swapErr)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("status after blocked swap = %+v, want version 6", status)
|
||||
}
|
||||
if _, err := os.Lstat(movedDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("moved directory exists after blocked Windows swap: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
return
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute destination changed: %q", got)
|
||||
}
|
||||
status := readStatusDocument(t, filepath.Join(movedDir, "daemon.status"))
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("bound status = %+v, want version 6", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
assertNoStatusTemps(t, movedDir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 6,
|
||||
},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err != nil {
|
||||
t.Fatalf("writeStatusFile in 0755 directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
t.Fatalf("status file not created in read-only custom directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileRejectsWritableStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{Paths: Paths{Status: path}, Version: 6},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err == nil || !strings.Contains(err.Error(), "no group/other write access") {
|
||||
t.Fatalf("writeStatusFile error = %v, want writable-directory rejection", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("status file created in writable directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestPrivateDirHardensBroadCurrentUserStatusDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
if err := privatedir.Ensure(dir); err != nil {
|
||||
t.Fatalf("privatedir.Ensure: %v", err)
|
||||
}
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
t.Fatalf("validate hardened status root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readStatusDocument(t *testing.T, path string) StatusFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("status is incomplete JSON: %v (content %q)", err, data)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func assertNoStatusTemps(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(dir, statusTempPattern))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary status files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func secureStatusTestDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesExistingProtectedDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build restrictive DACL: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read restrictive DACL: %v", err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, dacl, nil); err != nil {
|
||||
t.Skipf("cannot apply restrictive DACL: %v", err)
|
||||
}
|
||||
want := statusDACL(t, path)
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 2,
|
||||
},
|
||||
}
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if got := statusDACL(t, path); got != want {
|
||||
t.Fatalf("status DACL after replacement = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func statusDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read status DACL: %v", err)
|
||||
}
|
||||
text := descriptor.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
func prepareStatusReplacement(*os.Root, string, string) error { return nil }
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// prepareStatusReplacement copies the existing status document's DACL to the
|
||||
// complete staging file before Root.Rename replaces it. Both files are opened
|
||||
// relative to the already-bound Root directory handle, so preserving Windows
|
||||
// security metadata does not reintroduce a pathname/ancestor-swap boundary.
|
||||
func prepareStatusReplacement(root *os.Root, src, dst string) (returnErr error) {
|
||||
if filepath.Base(src) != src || filepath.Base(dst) != dst {
|
||||
return fmt.Errorf("status replacement names must be root-relative base names")
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open bound status directory for DACL preservation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close bound status directory for DACL preservation: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access bound status directory handle: %w", err)
|
||||
}
|
||||
var destination, source windows.Handle
|
||||
var openErr error
|
||||
if err := raw.Control(func(rootHandle uintptr) {
|
||||
destination, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), dst, windows.READ_CONTROL|windows.FILE_READ_ATTRIBUTES)
|
||||
if isMissingStatusObject(openErr) {
|
||||
openErr = nil // no destination descriptor exists to preserve
|
||||
return
|
||||
}
|
||||
if openErr != nil {
|
||||
return
|
||||
}
|
||||
source, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), src, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("open status files through bound directory: %w", err)
|
||||
}
|
||||
if destination != 0 {
|
||||
defer windows.CloseHandle(destination)
|
||||
}
|
||||
if source != 0 {
|
||||
defer windows.CloseHandle(source)
|
||||
}
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open status files for DACL preservation: %w", openErr)
|
||||
}
|
||||
if destination == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
descriptor, err := windows.GetSecurityInfo(destination, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL: %w", err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode existing status DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("existing status file has an unrestricted Windows DACL")
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL control: %w", err)
|
||||
}
|
||||
securityInfo := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION)
|
||||
if control&windows.SE_DACL_PROTECTED != 0 {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.PROTECTED_DACL_SECURITY_INFORMATION)
|
||||
} else {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.UNPROTECTED_DACL_SECURITY_INFORMATION)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(source, windows.SE_FILE_OBJECT, securityInfo, nil, nil, dacl, nil); err != nil {
|
||||
return fmt.Errorf("preserve existing status DACL on replacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openStatusSecurityHandle(root windows.Handle, name string, access uint32) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attributes := &windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: root,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
}
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
access,
|
||||
attributes,
|
||||
&status,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing status DACL operation on reparse point %q", name)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func isMissingStatusObject(err error) bool {
|
||||
return errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
|
||||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
|
||||
}
|
||||
@@ -64,6 +64,28 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
|
||||
}
|
||||
return acquireOpenedFileLock(file)
|
||||
}
|
||||
|
||||
// TryAcquireFileLockRoot attempts to lock name relative to an already-bound
|
||||
// directory root. It is intended for lifecycle owners that must not resolve the
|
||||
// root pathname again between validation and lock acquisition.
|
||||
func TryAcquireFileLockRoot(root *os.Root, name, displayPath string) (*FileLock, error) {
|
||||
if root == nil {
|
||||
return nil, errors.New("lockutil: lock root is nil")
|
||||
}
|
||||
name = filepath.Clean(name)
|
||||
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("lockutil: lock name %q escapes root", name)
|
||||
}
|
||||
file, err := openLockFileRoot(root, name, displayPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lockutil: open rooted lock file: %w", err)
|
||||
}
|
||||
return acquireOpenedFileLock(file)
|
||||
}
|
||||
|
||||
func acquireOpenedFileLock(file *os.File) (*FileLock, error) {
|
||||
state, contended, err := tryLockFile(file)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
@@ -82,6 +104,41 @@ func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
func openLockFileRoot(root *os.Root, name, displayPath string) (*os.File, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
file, createErr := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
if err := validateRootLockFile(file); err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, errors.New("refusing non-regular rooted lock file")
|
||||
}
|
||||
file, err := root.OpenFile(name, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
if !os.SameFile(info, opened) {
|
||||
return nil, errors.Join(errors.New("rooted lock file changed while opening"), file.Close())
|
||||
}
|
||||
if err := validateRootLockFile(file); err != nil {
|
||||
return nil, errors.Join(err, file.Close())
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// WriteMetadata replaces the diagnostic contents of the lock file while the
|
||||
// caller holds it. Metadata does not establish ownership; the kernel lock does.
|
||||
func (lock *FileLock) WriteMetadata(data []byte) error {
|
||||
|
||||
@@ -76,6 +76,20 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func validateRootLockFile(file *os.File) error {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(int(file.Fd()), &stat); err != nil {
|
||||
return fmt.Errorf("inspect rooted lock file: %w", err)
|
||||
}
|
||||
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return errors.New("refusing non-regular rooted lock file")
|
||||
}
|
||||
if stat.Nlink != 1 {
|
||||
return errors.New("refusing multiply-linked rooted lock file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryLockFile(file *os.File) (platformLockState, bool, error) {
|
||||
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
|
||||
@@ -73,6 +73,10 @@ func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr e
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func validateRootLockFile(file *os.File) error {
|
||||
return validateWindowsLockHandle(windows.Handle(file.Fd()), false)
|
||||
}
|
||||
|
||||
func openLockDirectoryAt(parent windows.Handle, name string) (windows.Handle, error) {
|
||||
handle, err := openLockPathAt(
|
||||
parent,
|
||||
|
||||
@@ -7,17 +7,50 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
// crashExitCode is returned when a top-level panic is recovered.
|
||||
const crashExitCode = 1
|
||||
|
||||
const crashTempPrefix = ".crash-report-"
|
||||
|
||||
// ErrCrashReportCommitted marks an error that happened after a complete crash
|
||||
// report was published. Callers may still use the returned path and should
|
||||
// surface the error as a cleanup warning rather than as a failed publication.
|
||||
var ErrCrashReportCommitted = errors.New("crash report publication committed")
|
||||
|
||||
type crashReportCommittedError struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *crashReportCommittedError) Error() string {
|
||||
return fmt.Sprintf("%v with warning: %v", ErrCrashReportCommitted, err.cause)
|
||||
}
|
||||
|
||||
func (err *crashReportCommittedError) Unwrap() []error {
|
||||
return []error{ErrCrashReportCommitted, err.cause}
|
||||
}
|
||||
|
||||
type crashReportHooks struct {
|
||||
beforePublish func()
|
||||
write func(*os.File, []byte) (int, error)
|
||||
link func(*os.Root, string, string) error
|
||||
renameNoReplace func(*os.Root, string, string) error
|
||||
remove func(*os.Root, string) error
|
||||
}
|
||||
|
||||
// FormatCrashReport renders a human-readable crash report.
|
||||
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
|
||||
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
|
||||
@@ -26,16 +59,189 @@ func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time)
|
||||
|
||||
// WriteCrashReport writes a crash report file into dir and returns its path.
|
||||
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return writeCrashReport(dir, label, recovered, stack, ts, crashReportHooks{})
|
||||
}
|
||||
|
||||
func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, hooks crashReportHooks) (path string, returnErr error) {
|
||||
absoluteDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve crash report directory: %w", err)
|
||||
}
|
||||
root, err := openCrashDirectory(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
|
||||
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
|
||||
committed := false
|
||||
defer func() {
|
||||
if err := root.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close crash report directory: %w", err))
|
||||
}
|
||||
if committed && returnErr != nil && !errors.Is(returnErr, ErrCrashReportCommitted) {
|
||||
returnErr = &crashReportCommittedError{cause: returnErr}
|
||||
}
|
||||
if !committed && returnErr != nil {
|
||||
path = ""
|
||||
}
|
||||
}()
|
||||
|
||||
name := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
|
||||
path = filepath.Join(dir, name)
|
||||
report, tempName, err := createCrashTemp(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if tempName == "" {
|
||||
return
|
||||
}
|
||||
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary crash report: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
data := []byte(FormatCrashReport(label, recovered, stack, ts))
|
||||
writeReport := hooks.write
|
||||
if writeReport == nil {
|
||||
writeReport = func(file *os.File, data []byte) (int, error) { return file.Write(data) }
|
||||
}
|
||||
written, err := writeReport(report, data)
|
||||
if err == nil && written != len(data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
closeErr := report.Close()
|
||||
if closeErr != nil {
|
||||
return "", errors.Join(fmt.Errorf("write crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
|
||||
}
|
||||
return "", fmt.Errorf("write crash report: %w", err)
|
||||
}
|
||||
if err := report.Sync(); err != nil {
|
||||
closeErr := report.Close()
|
||||
if closeErr != nil {
|
||||
return "", errors.Join(fmt.Errorf("sync crash report: %w", err), fmt.Errorf("close crash report: %w", closeErr))
|
||||
}
|
||||
return "", fmt.Errorf("sync crash report: %w", err)
|
||||
}
|
||||
if err := report.Close(); err != nil {
|
||||
return "", fmt.Errorf("close crash report: %w", err)
|
||||
}
|
||||
if hooks.beforePublish != nil {
|
||||
hooks.beforePublish()
|
||||
}
|
||||
publishName := name
|
||||
link := root.Link
|
||||
if hooks.link != nil {
|
||||
link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) }
|
||||
}
|
||||
if linkErr := link(tempName, name); linkErr != nil {
|
||||
publishName, err = publishCrashFallback(root, name, tempName, hooks.renameNoReplace)
|
||||
if err != nil {
|
||||
return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), err)
|
||||
}
|
||||
committed = true
|
||||
tempName = ""
|
||||
} else {
|
||||
committed = true
|
||||
}
|
||||
path = filepath.Join(dir, publishName)
|
||||
if !crashPathUsesRoot(root, absoluteDir) {
|
||||
path = ""
|
||||
}
|
||||
if tempName != "" {
|
||||
if err := removeCrashTemp(hooks, root, tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
tempName = ""
|
||||
return path, fmt.Errorf("remove temporary crash report: %w", err)
|
||||
}
|
||||
tempName = ""
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// publishCrashFallback atomically renames the complete staging file to an
|
||||
// unpredictable unused name. The no-replace operation is the collision check;
|
||||
// there is no check-then-rename window in which another report can be replaced.
|
||||
func publishCrashFallback(root *os.Root, timestampName, tempName string, rename func(*os.Root, string, string) error) (string, error) {
|
||||
if rename == nil {
|
||||
rename = renameNoReplace
|
||||
}
|
||||
base := strings.TrimSuffix(timestampName, filepath.Ext(timestampName))
|
||||
if suffix := strings.TrimPrefix(tempName, crashTempPrefix); suffix != "" && suffix != tempName {
|
||||
candidate := base + "-" + suffix + filepath.Ext(timestampName)
|
||||
if err := rename(root, tempName, candidate); err == nil {
|
||||
return candidate, nil
|
||||
} else if !errors.Is(err, os.ErrExist) {
|
||||
return "", fmt.Errorf("publish fallback crash report: %w", err)
|
||||
}
|
||||
}
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return "", fmt.Errorf("generate fallback crash report name: %w", err)
|
||||
}
|
||||
candidate := base + "-" + hex.EncodeToString(suffix[:]) + filepath.Ext(timestampName)
|
||||
if err := rename(root, tempName, candidate); err == nil {
|
||||
return candidate, nil
|
||||
} else if !errors.Is(err, os.ErrExist) {
|
||||
return "", fmt.Errorf("publish fallback crash report: %w", err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("generate fallback crash report name: exhausted unique names")
|
||||
}
|
||||
|
||||
func removeCrashTemp(hooks crashReportHooks, root *os.Root, name string) error {
|
||||
if hooks.remove != nil {
|
||||
return hooks.remove(root, name)
|
||||
}
|
||||
return root.Remove(name)
|
||||
}
|
||||
|
||||
func createCrashTemp(root *os.Root) (*os.File, string, error) {
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return nil, "", fmt.Errorf("generate temporary crash report name: %w", err)
|
||||
}
|
||||
name := crashTempPrefix + hex.EncodeToString(suffix[:])
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary crash report: %w", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary crash report: exhausted unique names")
|
||||
}
|
||||
|
||||
func crashPathUsesRoot(root *os.Root, dir string) bool {
|
||||
bound, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current, err := os.Stat(filepath.Clean(dir))
|
||||
return err == nil && os.SameFile(bound, current)
|
||||
}
|
||||
|
||||
func openCrashDirectory(dir string) (*os.Root, error) {
|
||||
clean := filepath.Clean(dir)
|
||||
defaultDir := filepath.Clean(DefaultCrashDir())
|
||||
parent := filepath.Dir(clean)
|
||||
// The default layout shares ~/.zero with the daemon runtime fallback. Keep
|
||||
// both the shared parent and the crash-report child private. Custom crash
|
||||
// destinations are hardened at the caller-supplied boundary only.
|
||||
if clean == defaultDir && filepath.Base(clean) == "crashes" && filepath.Base(parent) == ".zero" {
|
||||
if err := privatedir.Ensure(parent); err != nil {
|
||||
return nil, fmt.Errorf("secure crash report parent: %w", err)
|
||||
}
|
||||
}
|
||||
root, err := privatedir.Open(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secure crash report directory: %w", err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// DefaultCrashDir is where crash reports are written by default.
|
||||
func DefaultCrashDir() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
@@ -54,8 +260,22 @@ func Recover(dir, label string, stderr io.Writer, code *int) {
|
||||
return
|
||||
}
|
||||
stack := debug.Stack()
|
||||
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
|
||||
reportRecoveredCrash(stderr, code, recovered, stack, func() (string, error) {
|
||||
return WriteCrashReport(dir, label, recovered, stack, time.Now())
|
||||
})
|
||||
}
|
||||
|
||||
func reportRecoveredCrash(stderr io.Writer, code *int, recovered any, stack []byte, write func() (string, error)) {
|
||||
path, err := write()
|
||||
if err == nil || errors.Is(err, ErrCrashReportCommitted) {
|
||||
if path != "" {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved, but its current path could not be determined\n", recovered)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "Warning: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package observability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,6 +30,400 @@ func TestWriteAndFormatCrashReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportKeepsTwoReportsFromTheSameSecond(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC)
|
||||
first, err := WriteCrashReport(dir, "cli", "first panic", []byte("first stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := WriteCrashReport(dir, "cli", "second panic", []byte("second stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatalf("second report in the same second: %v", err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatalf("same-second reports shared path %q", first)
|
||||
}
|
||||
firstData, err := os.ReadFile(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondData, err := os.ReadFile(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(firstData), "first panic") || strings.Contains(string(firstData), "second panic") {
|
||||
t.Fatalf("first report was replaced: %q", firstData)
|
||||
}
|
||||
if !strings.Contains(string(secondData), "second panic") {
|
||||
t.Fatalf("second report was not persisted: %q", secondData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportCreatesPrivateDefaultDirectories(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
dir := DefaultCrashDir()
|
||||
if dir != filepath.Join(home, ".zero", "crashes") {
|
||||
t.Fatalf("DefaultCrashDir = %q, want path beneath temporary home", dir)
|
||||
}
|
||||
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("directory %s permissions = %04o, want owner-only", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportHardensPreexistingDefaultDirectories(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows DACL migration is covered by the daemon integration test")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
dir := DefaultCrashDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), time.Now()); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
for _, path := range []string{filepath.Join(home, ".zero"), dir} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("directory %s permissions = %04o after migration, want owner-only", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
var swapErr error
|
||||
path, err := writeCrashReport(dir, "cli", "secret panic", []byte("secret stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute crash directory: %v", err)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport: %v", err)
|
||||
}
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("swap crash directory: %v", swapErr)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("report missing after Windows blocked directory swap: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
|
||||
}
|
||||
reportName := "crash-" + ts.UTC().Format("20060102-150405") + ".log"
|
||||
data, err := os.ReadFile(filepath.Join(movedDir, reportName))
|
||||
if err != nil {
|
||||
t.Fatalf("read report through originally bound directory: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "secret panic") {
|
||||
t.Fatalf("report in bound directory missing panic: %q", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, reportName)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute directory received crash report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportPublishesOnlyCompleteContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
finalPath := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
staged := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
result := make(chan crashWriteResult, 1)
|
||||
go func() {
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
close(staged)
|
||||
<-release
|
||||
},
|
||||
})
|
||||
result <- crashWriteResult{path: path, err: err}
|
||||
}()
|
||||
select {
|
||||
case <-staged:
|
||||
case written := <-result:
|
||||
t.Fatalf("writeCrashReport returned before publication hook: %v", written.err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writeCrashReport did not reach publication hook")
|
||||
}
|
||||
defer func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
}()
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final report visible before complete publication: %v", err)
|
||||
}
|
||||
close(release)
|
||||
written := <-result
|
||||
if written.err != nil {
|
||||
t.Fatalf("writeCrashReport: %v", written.err)
|
||||
}
|
||||
data, err := os.ReadFile(written.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("published report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportRemovesTempAfterWriteFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
injected := errors.New("injected write failure")
|
||||
_, err := writeCrashReport(dir, "cli", "boom", []byte("stack"), time.Now(), crashReportHooks{
|
||||
write: func(*os.File, []byte) (int, error) { return 0, injected },
|
||||
})
|
||||
if !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want injected failure", err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("crash directory contains partial files after failure: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
path := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteCrashReport with existing timestamp: %v", err)
|
||||
}
|
||||
if published == path {
|
||||
t.Fatal("new report reused the occupied timestamp path")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "existing" {
|
||||
t.Fatalf("existing report overwritten: %q", data)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("crash directory should contain existing and uniquely published reports: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportPreservesPathAfterCommittedCleanupWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
injected := errors.New("injected cleanup failure")
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
remove: func(*os.Root, string) error { return injected },
|
||||
})
|
||||
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
|
||||
}
|
||||
wantPath := filepath.Join(dir, "crash-20260824-120000.log")
|
||||
if path != wantPath {
|
||||
t.Fatalf("writeCrashReport path = %q, want %q", path, wantPath)
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read committed crash report: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("committed crash report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportDoesNotReturnStalePathWithCleanupWarning(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injected := errors.New("injected cleanup failure")
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
var swapErr error
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
beforePublish: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr == nil {
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute crash directory: %v", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
remove: func(*os.Root, string) error { return injected },
|
||||
})
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("Windows kept the open crash directory from being renamed: %v", swapErr)
|
||||
}
|
||||
t.Fatalf("swap crash directory: %v", swapErr)
|
||||
}
|
||||
if !errors.Is(err, ErrCrashReportCommitted) || !errors.Is(err, injected) {
|
||||
t.Fatalf("writeCrashReport error = %v, want committed cleanup warning", err)
|
||||
}
|
||||
if path != "" {
|
||||
t.Fatalf("writeCrashReport returned stale path %q after directory swap", path)
|
||||
}
|
||||
report := filepath.Join(movedDir, "crash-20260824-120000.log")
|
||||
data, readErr := os.ReadFile(report)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read committed report through moved directory: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("committed report is incomplete: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportFallsBackWhenHardLinksAreUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
unsupported := errors.New("hard links unavailable")
|
||||
var collisionName string
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
link: func(root *os.Root, oldname, _ string) error {
|
||||
suffix := strings.TrimPrefix(oldname, crashTempPrefix)
|
||||
collisionName = "crash-20260824-120000-" + suffix + ".log"
|
||||
if err := root.WriteFile(collisionName, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatalf("create fallback-name collision: %v", err)
|
||||
}
|
||||
return unsupported
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport fallback: %v", err)
|
||||
}
|
||||
if path == filepath.Join(dir, collisionName) {
|
||||
t.Fatalf("fallback replaced the existing report %q", collisionName)
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(path), "crash-20260824-120000-") {
|
||||
t.Fatalf("fallback path = %q, want timestamp and random suffix", path)
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read fallback crash report: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(data), "complete stack") {
|
||||
t.Fatalf("fallback crash report is incomplete: %q", data)
|
||||
}
|
||||
existing, readErr := os.ReadFile(filepath.Join(dir, collisionName))
|
||||
if readErr != nil {
|
||||
t.Fatalf("read existing collision: %v", readErr)
|
||||
}
|
||||
if string(existing) != "existing" {
|
||||
t.Fatalf("existing fallback collision was overwritten: %q", existing)
|
||||
}
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("crash directory contains partial files after fallback: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCrashReportFallbackDoesNotReplaceCommitTimeCollision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ts := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
unsupported := errors.New("hard links unavailable")
|
||||
var collisionName string
|
||||
attempts := 0
|
||||
path, err := writeCrashReport(dir, "cli", "boom", []byte("complete stack"), ts, crashReportHooks{
|
||||
link: func(*os.Root, string, string) error { return unsupported },
|
||||
renameNoReplace: func(root *os.Root, oldname, newname string) error {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
collisionName = newname
|
||||
if err := root.WriteFile(newname, []byte("concurrent report"), 0o600); err != nil {
|
||||
t.Fatalf("create commit-time collision: %v", err)
|
||||
}
|
||||
}
|
||||
return renameNoReplace(root, oldname, newname)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeCrashReport fallback: %v", err)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("fallback rename attempts = %d, want collision retry", attempts)
|
||||
}
|
||||
if filepath.Base(path) == collisionName {
|
||||
t.Fatalf("fallback replaced the concurrent report %q", collisionName)
|
||||
}
|
||||
existing, err := os.ReadFile(filepath.Join(dir, collisionName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(existing) != "concurrent report" {
|
||||
t.Fatalf("concurrent report overwritten: %q", existing)
|
||||
}
|
||||
published, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(published), "complete stack") {
|
||||
t.Fatalf("published fallback is incomplete: %q", published)
|
||||
}
|
||||
}
|
||||
|
||||
type crashWriteResult struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func TestRecoverCapturesPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var stderr bytes.Buffer
|
||||
@@ -63,3 +460,25 @@ func TestRecoverNoPanicIsNoop(t *testing.T) {
|
||||
t.Fatalf("unexpected output without a panic: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportRecoveredCrashReportsCommittedCleanupWarning(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := 0
|
||||
injected := errors.New("injected cleanup failure")
|
||||
reportRecoveredCrash(&stderr, &code, "kaboom", []byte("secret stack"), func() (string, error) {
|
||||
return "/tmp/crash.log", &crashReportCommittedError{cause: injected}
|
||||
})
|
||||
|
||||
if code != crashExitCode {
|
||||
t.Fatalf("exit code = %d, want %d", code, crashExitCode)
|
||||
}
|
||||
output := stderr.String()
|
||||
for _, want := range []string{"kaboom", "saved to /tmp/crash.log", "Warning:", injected.Error()} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("crash notice missing %q: %q", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "secret stack") {
|
||||
t.Fatalf("committed report warning fell back to inline stack: %q", output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build darwin
|
||||
|
||||
package observability
|
||||
|
||||
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 crash directory for publication: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
return unix.RenameatxNp(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_EXCL)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build linux
|
||||
|
||||
package observability
|
||||
|
||||
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 crash directory for publication: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
return unix.Renameat2(int(directory.Fd()), oldname, int(directory.Fd()), newname, unix.RENAME_NOREPLACE)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func renameNoReplace(*os.Root, string, string) error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build windows
|
||||
|
||||
package observability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type fileRenameInformation struct {
|
||||
replaceIfExists uint32
|
||||
rootDirectory windows.Handle
|
||||
fileNameLength uint32
|
||||
fileName [1]uint16
|
||||
}
|
||||
|
||||
func renameNoReplace(root *os.Root, oldname, newname string) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer directory.Close()
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := raw.Control(func(rawHandle uintptr) {
|
||||
rootHandle := windows.Handle(rawHandle)
|
||||
oldObjectName, nameErr := windows.NewNTUnicodeString(oldname)
|
||||
if nameErr != nil {
|
||||
returnErr = nameErr
|
||||
return
|
||||
}
|
||||
attributes := &windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: rootHandle,
|
||||
ObjectName: oldObjectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
}
|
||||
var source windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
returnErr = windows.NtCreateFile(
|
||||
&source,
|
||||
windows.DELETE|windows.SYNCHRONIZE,
|
||||
attributes,
|
||||
&status,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if returnErr != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(source)
|
||||
|
||||
newName, nameErr := windows.UTF16FromString(newname)
|
||||
if nameErr != nil {
|
||||
returnErr = nameErr
|
||||
return
|
||||
}
|
||||
nameBytes := (len(newName) - 1) * 2
|
||||
var layout fileRenameInformation
|
||||
bufferSize := int(unsafe.Offsetof(layout.fileName)) + nameBytes
|
||||
buffer := make([]byte, bufferSize)
|
||||
info := (*fileRenameInformation)(unsafe.Pointer(&buffer[0]))
|
||||
info.rootDirectory = rootHandle
|
||||
info.fileNameLength = uint32(nameBytes)
|
||||
copy((*[windows.MAX_LONG_PATH]uint16)(unsafe.Pointer(&info.fileName[0]))[:nameBytes/2:nameBytes/2], newName)
|
||||
returnErr = windows.NtSetInformationFile(source, &status, &buffer[0], uint32(bufferSize), windows.FileRenameInformation)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if errors.Is(returnErr, windows.STATUS_OBJECT_NAME_COLLISION) || errors.Is(returnErr, windows.STATUS_OBJECT_NAME_EXISTS) {
|
||||
return os.ErrExist
|
||||
}
|
||||
return returnErr
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package privatedir creates and safely hardens current-user-owned state
|
||||
// directories without applying permission changes through a path-only check.
|
||||
package privatedir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Ensure creates path when needed and enforces owner-only access. Existing
|
||||
// directories are hardened only after ownership is verified through a bound
|
||||
// directory handle; foreign-owned paths fail closed.
|
||||
func Ensure(path string) error {
|
||||
root, err := Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
return fmt.Errorf("close private directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open creates path when needed, enforces owner-only access, and returns a
|
||||
// traversal-resistant handle that callers can retain across subsequent I/O.
|
||||
func Open(path string) (*os.Root, error) {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve private directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(absolute, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create private directory: %w", err)
|
||||
}
|
||||
before, err := os.Lstat(absolute)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect private directory entry: %w", err)
|
||||
}
|
||||
if before.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("private directory entry is a symbolic link")
|
||||
}
|
||||
if !before.IsDir() {
|
||||
return nil, fmt.Errorf("private directory path is not a directory")
|
||||
}
|
||||
root, err := os.OpenRoot(absolute)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open private directory: %w", err)
|
||||
}
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("inspect opened private directory: %w", err)
|
||||
}
|
||||
after, err := os.Lstat(absolute)
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("reinspect private directory entry: %w", err)
|
||||
}
|
||||
if after.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, after) {
|
||||
_ = root.Close()
|
||||
return nil, fmt.Errorf("private directory entry changed while opening")
|
||||
}
|
||||
if err := harden(root); err != nil {
|
||||
closeErr := root.Close()
|
||||
if closeErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
current, err := os.Lstat(absolute)
|
||||
if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
|
||||
closeErr := root.Close()
|
||||
if err == nil {
|
||||
err = fmt.Errorf("private directory entry changed while hardening")
|
||||
} else {
|
||||
err = fmt.Errorf("verify private directory entry after hardening: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("close private directory: %w", closeErr))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package privatedir
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenRejectsFinalDirectorySymlinkWithoutHardeningTarget(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating directory symlinks requires privileges on some Windows runners")
|
||||
}
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "target")
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(parent, "private")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := Open(link); err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
||||
t.Fatalf("Open symlink error = %v, want symbolic-link rejection", err)
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o755 {
|
||||
t.Fatalf("symlink target permissions = %04o, want unchanged 0755", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build !windows
|
||||
|
||||
package privatedir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func harden(root *os.Root) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open private directory hardening handle: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
|
||||
}
|
||||
}()
|
||||
info, err := directory.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect private directory before hardening: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("private directory path is not a directory")
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("private directory ownership metadata is unavailable")
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("private directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
if err := directory.Chmod(0o700); err != nil {
|
||||
return fmt.Errorf("harden private directory permissions: %w", err)
|
||||
}
|
||||
info, err = directory.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify private directory permissions: %w", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
return fmt.Errorf("private directory permissions are %04o, want owner-only", info.Mode().Perm())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//go:build windows
|
||||
|
||||
package privatedir
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func harden(root *os.Root) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open private directory hardening handle: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory hardening handle: %w", err))
|
||||
}
|
||||
}()
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access private directory hardening handle: %w", err)
|
||||
}
|
||||
// Root.Open uses NtCreateFile, so ReOpenFile cannot accept its handle. An
|
||||
// empty relative NT object name reopens the bound directory itself with the
|
||||
// security rights needed for the DACL update and no pathname re-resolution.
|
||||
var securityHandle windows.Handle
|
||||
var openErr error
|
||||
if err := raw.Control(func(rawHandle uintptr) {
|
||||
objectName, nameErr := windows.NewNTUnicodeString("")
|
||||
if nameErr != nil {
|
||||
openErr = nameErr
|
||||
return
|
||||
}
|
||||
openErr = windows.NtCreateFile(
|
||||
&securityHandle,
|
||||
windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE,
|
||||
&windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: windows.Handle(rawHandle),
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
},
|
||||
&windows.IO_STATUS_BLOCK{},
|
||||
nil,
|
||||
windows.FILE_ATTRIBUTE_DIRECTORY,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT|windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("open private directory security handle: %w", err)
|
||||
}
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open private directory with security access: %w", openErr)
|
||||
}
|
||||
if securityHandle == 0 || securityHandle == windows.InvalidHandle {
|
||||
return fmt.Errorf("open private directory with security access: invalid handle")
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(securityHandle); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close private directory security handle: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows user: %w", err)
|
||||
}
|
||||
tokenOwner, err := windowsTokenOwner(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows token owner: %w", err)
|
||||
}
|
||||
current, err := windows.GetSecurityInfo(securityHandle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private directory owner before hardening: %w", err)
|
||||
}
|
||||
owner, _, err := current.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private directory owner before hardening: %w", err)
|
||||
}
|
||||
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
|
||||
return fmt.Errorf("private directory is not owned by the current Windows token")
|
||||
}
|
||||
desired, err := windows.SecurityDescriptorFromString(
|
||||
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build private Windows directory DACL: %w", err)
|
||||
}
|
||||
dacl, _, err := desired.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private Windows directory DACL: %w", err)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(
|
||||
securityHandle,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
dacl,
|
||||
nil,
|
||||
); err != nil {
|
||||
return fmt.Errorf("harden private Windows directory DACL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tokenOwnerInfo struct {
|
||||
owner *windows.SID
|
||||
}
|
||||
|
||||
func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := (*tokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
Reference in new issue
Block a user