From a3498619da27ef2cf62e546212dd49733e6bc72b Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 1 Jun 2026 22:28:42 -0400 Subject: [PATCH 1/6] Add safe daemon Unix socket listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon servers need a kit-owned startup primitive so applications do not reimplement stale Unix socket cleanup around net.Listen. Without serialization, concurrent starts can both classify a socket as stale and one process can remove the other process's newly bound socket. This adds a daemon.Listen helper that holds an inter-process listen lock across stale socket probing, removal, and bind. The listen lock is separate from the Manager auto-start lock so detached child daemons can bind while the parent waits for discovery. Validation: go test ./daemon; go test ./... 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: OpenAI Codex --- daemon/listen.go | 119 ++++++++++++++++++++++ daemon/listen_unix.go | 12 +++ daemon/listen_unix_test.go | 195 +++++++++++++++++++++++++++++++++++++ daemon/listen_windows.go | 7 ++ daemon/lock.go | 56 +++++++++++ daemon/manager.go | 44 +-------- daemon/runtime.go | 13 +++ daemon/runtime_test.go | 12 +++ 8 files changed, 415 insertions(+), 43 deletions(-) create mode 100644 daemon/listen.go create mode 100644 daemon/listen_unix.go create mode 100644 daemon/listen_unix_test.go create mode 100644 daemon/listen_windows.go create mode 100644 daemon/lock.go diff --git a/daemon/listen.go b/daemon/listen.go new file mode 100644 index 0000000..36d2cbb --- /dev/null +++ b/daemon/listen.go @@ -0,0 +1,119 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "runtime" + "time" +) + +const defaultStaleSocketProbeTimeout = 500 * time.Millisecond + +// ListenOptions configures Listen. +type ListenOptions struct { + // Store provides the daemon runtime listen lock used to serialize Unix + // socket stale cleanup and bind. Ignored when LockPath is set. + Store RuntimeStore + + // LockPath overrides the lock file used for Unix socket startup. + LockPath string + + // StaleSocketProbeTimeout bounds the local dial used to prove that an + // existing Unix socket path is stale before removing it. + StaleSocketProbeTimeout time.Duration +} + +// Listen binds ep for daemon serving. +// +// For Unix sockets, Listen serializes stale socket probing/removal and the +// subsequent bind under an inter-process lock. Existing live sockets and +// non-socket paths are rejected. TCP endpoints and Windows retain Endpoint's +// normal Listen behavior. +func Listen(ctx context.Context, ep Endpoint, opts ListenOptions) (net.Listener, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if !ep.IsUnix() || runtime.GOOS == "windows" { + return ep.Listen() + } + lockPath, err := opts.listenLockPath(ep) + if err != nil { + return nil, err + } + unlock, err := acquireDaemonLock(ctx, lockPath, "acquire daemon listen lock") + if err != nil { + return nil, err + } + defer unlock() + if err := removeStaleUnixSocket(ctx, ep, opts); err != nil { + return nil, err + } + return ep.Listen() +} + +func (opts ListenOptions) listenLockPath(ep Endpoint) (string, error) { + if opts.LockPath != "" { + return opts.LockPath, nil + } + if opts.Store.Dir != "" { + return opts.Store.ListenLockPath() + } + if ep.Address == "" { + return "", fmt.Errorf("empty daemon endpoint address") + } + return ep.Address + ".lock", nil +} + +func (opts ListenOptions) staleSocketProbeTimeout() time.Duration { + if opts.StaleSocketProbeTimeout > 0 { + return opts.StaleSocketProbeTimeout + } + return defaultStaleSocketProbeTimeout +} + +func removeStaleUnixSocket(ctx context.Context, ep Endpoint, opts ListenOptions) error { + if ep.Address == "" { + return fmt.Errorf("empty daemon endpoint address") + } + info, err := os.Lstat(ep.Address) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("inspect unix socket %s: %w", ep.Address, err) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("refusing to remove non-socket path %s", ep.Address) + } + stale, err := unixSocketStale(ctx, ep.Address, opts.staleSocketProbeTimeout()) + if err != nil { + return err + } + if !stale { + return fmt.Errorf("daemon already listening on unix socket %s", ep.Address) + } + if err := os.Remove(ep.Address); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale unix socket %s: %w", ep.Address, err) + } + return nil +} + +func unixSocketStale(ctx context.Context, path string, timeout time.Duration) (bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + conn, err := (&net.Dialer{}).DialContext(probeCtx, NetworkUnix, path) + if err == nil { + _ = conn.Close() + return false, nil + } + if ctxErr := probeCtx.Err(); ctxErr != nil { + return false, fmt.Errorf("probe unix socket %s: %w", path, ctxErr) + } + if isStaleUnixSocketDialError(err) { + return true, nil + } + return false, fmt.Errorf("probe unix socket %s: %w", path, err) +} diff --git a/daemon/listen_unix.go b/daemon/listen_unix.go new file mode 100644 index 0000000..d2bc201 --- /dev/null +++ b/daemon/listen_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package daemon + +import ( + "errors" + "syscall" +) + +func isStaleUnixSocketDialError(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) +} diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go new file mode 100644 index 0000000..a86a0f6 --- /dev/null +++ b/daemon/listen_unix_test.go @@ -0,0 +1,195 @@ +//go:build !windows + +package daemon_test + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/gofrs/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/kit/daemon" +) + +func TestListenUnixRemovesStaleSocketAndBinds(t *testing.T) { + socketPath := staleUnixSocket(t) + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestListenUnixRejectsNonSocketPath(t *testing.T) { + socketPath := unixSocketPath(t) + require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600)) + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "refusing to remove non-socket path") + + body, readErr := os.ReadFile(socketPath) + require.NoError(t, readErr) + assert.Equal(t, "not a socket", string(body)) +} + +func TestListenUnixRejectsLiveSocket(t *testing.T) { + socketPath := unixSocketPath(t) + live, err := net.Listen(daemon.NetworkUnix, socketPath) + require.NoError(t, err) + t.Cleanup(func() { _ = live.Close() }) + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "daemon already listening") + + conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestListenUnixSerializesConcurrentStaleSocketStartup(t *testing.T) { + socketPath := staleUnixSocket(t) + lockPath := filepath.Join(filepath.Dir(socketPath), "daemon.lock") + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + opts := daemon.ListenOptions{LockPath: lockPath} + + const starters = 16 + start := make(chan struct{}) + results := make(chan listenResult, starters) + for range starters { + go func() { + <-start + listener, err := daemon.Listen(context.Background(), ep, opts) + results <- listenResult{listener: listener, err: err} + }() + } + close(start) + + var winner net.Listener + var errors []error + for range starters { + result := <-results + if result.err == nil { + require.Nil(t, winner, "only one daemon start should bind the socket") + winner = result.listener + continue + } + errors = append(errors, result.err) + } + require.NotNil(t, winner) + t.Cleanup(func() { _ = winner.Close() }) + require.Len(t, errors, starters-1) + for _, err := range errors { + assert.True(t, + strings.Contains(err.Error(), "daemon already listening") || + strings.Contains(err.Error(), "bind: address already in use"), + "unexpected listen error: %v", err) + } + + conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestListenUnixProbesAfterAcquiringLock(t *testing.T) { + socketPath := staleUnixSocket(t) + lockPath := filepath.Join(filepath.Dir(socketPath), "daemon.lock") + heldLock := flock.New(lockPath) + require.NoError(t, heldLock.Lock()) + locked := true + t.Cleanup(func() { + if locked { + _ = heldLock.Unlock() + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + resultCh := make(chan listenResult, 1) + go func() { + listener, err := daemon.Listen(ctx, ep, daemon.ListenOptions{LockPath: lockPath}) + resultCh <- listenResult{listener: listener, err: err} + }() + + require.NoError(t, os.Remove(socketPath)) + live, err := net.Listen(daemon.NetworkUnix, socketPath) + require.NoError(t, err) + t.Cleanup(func() { _ = live.Close() }) + + require.NoError(t, heldLock.Unlock()) + locked = false + result := <-resultCh + require.Error(t, result.err) + assert.Nil(t, result.listener) + assert.Contains(t, result.err.Error(), "daemon already listening") + + conn, err := net.DialTimeout(daemon.NetworkUnix, socketPath, time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { + socketPath := staleUnixSocket(t) + base, err := os.MkdirTemp("/tmp", "kitd-lock") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(base) }) + target := filepath.Join(base, "target") + link := filepath.Join(base, "link") + require.NoError(t, os.MkdirAll(target, 0o700)) + require.NoError(t, os.Symlink(target, link)) + + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ + LockPath: filepath.Join(link, "daemon.lock"), + }) + + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "prepare daemon lock dir") + assert.Contains(t, err.Error(), "symlink") + _, statErr := os.Lstat(socketPath) + require.NoError(t, statErr, "stale socket should not be touched when lock dir is unsafe") +} + +type listenResult struct { + listener net.Listener + err error +} + +func staleUnixSocket(t *testing.T) string { + t.Helper() + socketPath := unixSocketPath(t) + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + require.NoError(t, err) + defer func() { _ = syscall.Close(fd) }() + require.NoError(t, syscall.Bind(fd, &syscall.SockaddrUnix{Name: socketPath})) + if _, err := os.Lstat(socketPath); err != nil { + t.Fatalf("bound unix socket did not leave a socket path: %v", err) + } + return socketPath +} + +func unixSocketPath(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "kitd") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "d.sock") +} diff --git a/daemon/listen_windows.go b/daemon/listen_windows.go new file mode 100644 index 0000000..883ae10 --- /dev/null +++ b/daemon/listen_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package daemon + +func isStaleUnixSocketDialError(error) bool { + return false +} diff --git a/daemon/lock.go b/daemon/lock.go new file mode 100644 index 0000000..072798b --- /dev/null +++ b/daemon/lock.go @@ -0,0 +1,56 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/gofrs/flock" + "golang.org/x/sync/semaphore" +) + +var daemonLocks sync.Map + +// daemonLockRetryDelay is the poll interval; the caller context bounds total wait. +const daemonLockRetryDelay = 50 * time.Millisecond + +type daemonLock struct { + local *semaphore.Weighted + file *flock.Flock +} + +func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), error) { + if lockPath == "" { + return nil, fmt.Errorf("%s: empty daemon lock path", action) + } + if err := ensurePrivateRuntimeDir(filepath.Dir(lockPath)); err != nil { + return nil, fmt.Errorf("prepare daemon lock dir: %w", err) + } + value, _ := daemonLocks.LoadOrStore(lockPath, &daemonLock{ + local: semaphore.NewWeighted(1), + file: flock.New(lockPath), + }) + lock := value.(*daemonLock) + if err := lock.local.Acquire(ctx, 1); err != nil { + return nil, fmt.Errorf("%s: %w", action, err) + } + locked, err := lock.file.TryLockContext(ctx, daemonLockRetryDelay) + if err != nil { + lock.local.Release(1) + return nil, fmt.Errorf("%s: %w", action, err) + } + if !locked { + lock.local.Release(1) + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("%s: %w", action, err) + } + return nil, errors.New(action + ": lock not acquired") + } + return func() { + _ = lock.file.Unlock() + lock.local.Release(1) + }, nil +} diff --git a/daemon/manager.go b/daemon/manager.go index f92f1f7..3a8c165 100644 --- a/daemon/manager.go +++ b/daemon/manager.go @@ -4,25 +4,9 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" - "sync" "time" - - "github.com/gofrs/flock" - "golang.org/x/sync/semaphore" ) -var startLocks sync.Map - -// startLockRetryDelay is the poll interval; the caller context bounds total wait. -const startLockRetryDelay = 50 * time.Millisecond - -type startLock struct { - local *semaphore.Weighted - file *flock.Flock -} - // CompatibleFunc returns true when a discovered daemon can serve this client. type CompatibleFunc func(RuntimeRecord, PingInfo) bool @@ -102,31 +86,5 @@ func (m Manager) lockStart(ctx context.Context) (func(), error) { if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { - return nil, fmt.Errorf("mkdir daemon lock dir: %w", err) - } - value, _ := startLocks.LoadOrStore(lockPath, &startLock{ - local: semaphore.NewWeighted(1), - file: flock.New(lockPath), - }) - lock := value.(*startLock) - if err := lock.local.Acquire(ctx, 1); err != nil { - return nil, fmt.Errorf("acquire daemon start lock: %w", err) - } - locked, err := lock.file.TryLockContext(ctx, startLockRetryDelay) - if err != nil { - lock.local.Release(1) - return nil, fmt.Errorf("acquire daemon start lock: %w", err) - } - if !locked { - lock.local.Release(1) - if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("acquire daemon start lock: %w", err) - } - return nil, errors.New("daemon start lock not acquired") - } - return func() { - _ = lock.file.Unlock() - lock.local.Release(1) - }, nil + return acquireDaemonLock(ctx, lockPath, "acquire daemon start lock") } diff --git a/daemon/runtime.go b/daemon/runtime.go index da19c5b..8eb153f 100644 --- a/daemon/runtime.go +++ b/daemon/runtime.go @@ -109,6 +109,19 @@ func (s RuntimeStore) LockPath() (string, error) { return filepath.Join(s.Dir, prefix+".lock"), nil } +// ListenLockPath returns the path used to serialize daemon server listen setup +// for the store. +func (s RuntimeStore) ListenLockPath() (string, error) { + prefix, err := s.validatePrefix() + if err != nil { + return "", err + } + if err := s.prepareDir(); err != nil { + return "", err + } + return filepath.Join(s.Dir, prefix+".listen.lock"), nil +} + // Write saves rec atomically and returns the final path. func (s RuntimeStore) Write(rec RuntimeRecord) (string, error) { if err := s.prepareDir(); err != nil { diff --git a/daemon/runtime_test.go b/daemon/runtime_test.go index e8ef707..257fa92 100644 --- a/daemon/runtime_test.go +++ b/daemon/runtime_test.go @@ -82,3 +82,15 @@ func TestRuntimeStoreRejectsPrefixTraversal(t *testing.T) { _, err = store.CleanupDead() require.Error(t, err) } + +func TestRuntimeStoreListenLockPathIsSeparateFromStartLock(t *testing.T) { + store := daemon.RuntimeStore{Dir: t.TempDir(), Prefix: "kata"} + + startLock, err := store.LockPath() + require.NoError(t, err) + listenLock, err := store.ListenLockPath() + require.NoError(t, err) + + assert.Equal(t, filepath.Join(store.Dir, "kata.lock"), startLock) + assert.Equal(t, filepath.Join(store.Dir, "kata.listen.lock"), listenLock) +} From c335271aee154f57b58c713cc00246aa8e328f7b Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 2 Jun 2026 08:22:19 -0400 Subject: [PATCH 2/6] Reject relative daemon lock paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon lock acquisition now runs safefile private-directory hardening before taking the file lock. A bare relative lock path would make the lock directory resolve to the process working directory, so the hardening step could unexpectedly chmod the caller's cwd. Rejecting relative lock paths preserves the safefile protection without letting arbitrary process state become the lock directory. The regression covers both explicit relative LockPath input and relative Unix endpoint-derived lock paths. Validation: go test ./daemon; go test ./... 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: OpenAI Codex --- daemon/listen_unix_test.go | 24 ++++++++++++++++++++++++ daemon/lock.go | 3 +++ 2 files changed, 27 insertions(+) diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go index a86a0f6..cae4580 100644 --- a/daemon/listen_unix_test.go +++ b/daemon/listen_unix_test.go @@ -168,6 +168,30 @@ func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { require.NoError(t, statErr, "stale socket should not be touched when lock dir is unsafe") } +func TestListenUnixRejectsRelativeLockPaths(t *testing.T) { + cases := map[string]struct { + ep daemon.Endpoint + opts daemon.ListenOptions + }{ + "explicit lock path": { + ep: daemon.Endpoint{Network: daemon.NetworkUnix, Address: unixSocketPath(t)}, + opts: daemon.ListenOptions{LockPath: "daemon.lock"}, + }, + "derived lock path": { + ep: daemon.Endpoint{Network: daemon.NetworkUnix, Address: "daemon.sock"}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + listener, err := daemon.Listen(context.Background(), tc.ep, tc.opts) + + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "must be absolute") + }) + } +} + type listenResult struct { listener net.Listener err error diff --git a/daemon/lock.go b/daemon/lock.go index 072798b..da3cc88 100644 --- a/daemon/lock.go +++ b/daemon/lock.go @@ -26,6 +26,9 @@ func acquireDaemonLock(ctx context.Context, lockPath, action string) (func(), er if lockPath == "" { return nil, fmt.Errorf("%s: empty daemon lock path", action) } + if !filepath.IsAbs(lockPath) { + return nil, fmt.Errorf("%s: daemon lock path %q must be absolute", action, lockPath) + } if err := ensurePrivateRuntimeDir(filepath.Dir(lockPath)); err != nil { return nil, fmt.Errorf("prepare daemon lock dir: %w", err) } From 53c40583a81f68e4d5603170f506c845f6417cff Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 2 Jun 2026 08:30:13 -0400 Subject: [PATCH 3/6] Harden daemon Unix listen paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unix listen helper now validates the socket path itself before using any store or explicit lock path. That keeps callers from placing the API socket in a shared directory while the lock lives somewhere private, and preserves ParseEndpoint's absolute-path invariant even for manually constructed Endpoint values. The stale probe also treats ENOENT as a free socket race, which lets startup proceed when another listener closes and unlinks between Lstat and Dial. Validation: go test ./daemon; go test ./... 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: OpenAI Codex --- daemon/listen.go | 17 ++++++++ daemon/listen_internal_unix_test.go | 25 ++++++++++++ daemon/listen_unix.go | 2 +- daemon/listen_unix_test.go | 60 ++++++++++++++++++----------- 4 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 daemon/listen_internal_unix_test.go diff --git a/daemon/listen.go b/daemon/listen.go index 36d2cbb..3658468 100644 --- a/daemon/listen.go +++ b/daemon/listen.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "os" + "path/filepath" "runtime" "time" ) @@ -39,6 +40,9 @@ func Listen(ctx context.Context, ep Endpoint, opts ListenOptions) (net.Listener, if !ep.IsUnix() || runtime.GOOS == "windows" { return ep.Listen() } + if err := prepareUnixListenEndpoint(ep); err != nil { + return nil, err + } lockPath, err := opts.listenLockPath(ep) if err != nil { return nil, err @@ -54,6 +58,19 @@ func Listen(ctx context.Context, ep Endpoint, opts ListenOptions) (net.Listener, return ep.Listen() } +func prepareUnixListenEndpoint(ep Endpoint) error { + if ep.Address == "" { + return fmt.Errorf("empty daemon endpoint address") + } + if !filepath.IsAbs(ep.Address) { + return fmt.Errorf("unix socket path %q must be absolute", ep.Address) + } + if err := ensurePrivateRuntimeDir(filepath.Dir(ep.Address)); err != nil { + return fmt.Errorf("prepare unix socket dir: %w", err) + } + return nil +} + func (opts ListenOptions) listenLockPath(ep Endpoint) (string, error) { if opts.LockPath != "" { return opts.LockPath, nil diff --git a/daemon/listen_internal_unix_test.go b/daemon/listen_internal_unix_test.go new file mode 100644 index 0000000..e406883 --- /dev/null +++ b/daemon/listen_internal_unix_test.go @@ -0,0 +1,25 @@ +//go:build !windows + +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnixSocketStaleTreatsMissingSocketAsStale(t *testing.T) { + dir, err := os.MkdirTemp("/tmp", "kitd-probe") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + stale, err := unixSocketStale(context.Background(), filepath.Join(dir, "missing.sock"), 50*time.Millisecond) + + require.NoError(t, err) + assert.True(t, stale) +} diff --git a/daemon/listen_unix.go b/daemon/listen_unix.go index d2bc201..ddd6f1a 100644 --- a/daemon/listen_unix.go +++ b/daemon/listen_unix.go @@ -8,5 +8,5 @@ import ( ) func isStaleUnixSocketDialError(err error) bool { - return errors.Is(err, syscall.ECONNREFUSED) + return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT) } diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go index cae4580..2476d36 100644 --- a/daemon/listen_unix_test.go +++ b/daemon/listen_unix_test.go @@ -168,28 +168,44 @@ func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { require.NoError(t, statErr, "stale socket should not be touched when lock dir is unsafe") } -func TestListenUnixRejectsRelativeLockPaths(t *testing.T) { - cases := map[string]struct { - ep daemon.Endpoint - opts daemon.ListenOptions - }{ - "explicit lock path": { - ep: daemon.Endpoint{Network: daemon.NetworkUnix, Address: unixSocketPath(t)}, - opts: daemon.ListenOptions{LockPath: "daemon.lock"}, - }, - "derived lock path": { - ep: daemon.Endpoint{Network: daemon.NetworkUnix, Address: "daemon.sock"}, - }, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - listener, err := daemon.Listen(context.Background(), tc.ep, tc.opts) - - require.Error(t, err) - assert.Nil(t, listener) - assert.Contains(t, err.Error(), "must be absolute") - }) - } +func TestListenUnixRejectsRelativeLockPath(t *testing.T) { + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: unixSocketPath(t)} + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ + LockPath: "daemon.lock", + }) + + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "daemon lock path") + assert.Contains(t, err.Error(), "must be absolute") +} + +func TestListenUnixRejectsRelativeSocketPath(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "daemon.lock") + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: "daemon.sock"} + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ + LockPath: lockPath, + }) + + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "unix socket path") + assert.Contains(t, err.Error(), "must be absolute") +} + +func TestListenUnixRejectsSharedSocketDirectoryEvenWithStoreLock(t *testing.T) { + socketPath := filepath.Join("/tmp", "kitd-shared-socket.sock") + t.Cleanup(func() { _ = os.Remove(socketPath) }) + ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} + listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ + Store: daemon.RuntimeStore{Dir: t.TempDir()}, + }) + + require.Error(t, err) + assert.Nil(t, listener) + assert.Contains(t, err.Error(), "prepare unix socket dir") + _, statErr := os.Lstat(socketPath) + assert.True(t, os.IsNotExist(statErr), "socket in shared dir should not be created: %v", statErr) } type listenResult struct { From 59a1220713b8156d6ca7468d493134d689802492 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 2 Jun 2026 08:49:25 -0400 Subject: [PATCH 4/6] Validate daemon listen directories without repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon Unix socket parent checks must reject unsafe directories without chmodding arbitrary caller paths such as $HOME or /tmp. Add a validation-only safefile primitive and use it for socket parents, while keeping repair-capable EnsurePrivateDir for runtime directories that kit intentionally owns. RuntimeStore now rejects relative directories before preparation so store-derived lock paths cannot trigger caller-relative directory creation or chmod. Validation: go test ./daemon ./safefileio; go test ./... 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: OpenAI Codex --- daemon/listen.go | 4 ++-- daemon/listen_unix_test.go | 2 +- daemon/runtime.go | 3 +++ daemon/runtime_test.go | 11 +++++++++ daemon/safefileio.go | 4 ++++ safefileio/private_dir_unix.go | 29 +++++++++++++++++++++++ safefileio/private_dir_unix_test.go | 23 ++++++++++++++++++ safefileio/private_dir_windows.go | 32 ++++++++++++++++++++++++++ safefileio/private_dir_windows_test.go | 7 ++++++ 9 files changed, 112 insertions(+), 3 deletions(-) diff --git a/daemon/listen.go b/daemon/listen.go index 3658468..baeeeec 100644 --- a/daemon/listen.go +++ b/daemon/listen.go @@ -65,8 +65,8 @@ func prepareUnixListenEndpoint(ep Endpoint) error { if !filepath.IsAbs(ep.Address) { return fmt.Errorf("unix socket path %q must be absolute", ep.Address) } - if err := ensurePrivateRuntimeDir(filepath.Dir(ep.Address)); err != nil { - return fmt.Errorf("prepare unix socket dir: %w", err) + if err := validatePrivateRuntimeDir(filepath.Dir(ep.Address)); err != nil { + return fmt.Errorf("validate unix socket dir: %w", err) } return nil } diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go index 2476d36..e9ce30e 100644 --- a/daemon/listen_unix_test.go +++ b/daemon/listen_unix_test.go @@ -203,7 +203,7 @@ func TestListenUnixRejectsSharedSocketDirectoryEvenWithStoreLock(t *testing.T) { require.Error(t, err) assert.Nil(t, listener) - assert.Contains(t, err.Error(), "prepare unix socket dir") + assert.Contains(t, err.Error(), "validate unix socket dir") _, statErr := os.Lstat(socketPath) assert.True(t, os.IsNotExist(statErr), "socket in shared dir should not be created: %v", statErr) } diff --git a/daemon/runtime.go b/daemon/runtime.go index 8eb153f..62ed1c2 100644 --- a/daemon/runtime.go +++ b/daemon/runtime.go @@ -79,6 +79,9 @@ func (s RuntimeStore) prepareDir() error { if s.Dir == "" { return fmt.Errorf("runtime dir is empty") } + if !filepath.IsAbs(s.Dir) { + return fmt.Errorf("runtime dir %q must be absolute", s.Dir) + } if err := ensurePrivateRuntimeDir(s.Dir); err != nil { return fmt.Errorf("prepare runtime dir: %w", err) } diff --git a/daemon/runtime_test.go b/daemon/runtime_test.go index 257fa92..90b7d49 100644 --- a/daemon/runtime_test.go +++ b/daemon/runtime_test.go @@ -83,6 +83,17 @@ func TestRuntimeStoreRejectsPrefixTraversal(t *testing.T) { require.Error(t, err) } +func TestRuntimeStoreRejectsRelativeDirBeforePreparing(t *testing.T) { + store := daemon.RuntimeStore{Dir: "relative-runtime"} + + _, err := store.LockPath() + require.Error(t, err) + assert.Contains(t, err.Error(), "must be absolute") + + _, statErr := os.Stat("relative-runtime") + assert.True(t, os.IsNotExist(statErr), "relative runtime dir should not be created: %v", statErr) +} + func TestRuntimeStoreListenLockPathIsSeparateFromStartLock(t *testing.T) { store := daemon.RuntimeStore{Dir: t.TempDir(), Prefix: "kata"} diff --git a/daemon/safefileio.go b/daemon/safefileio.go index b02674d..0bd7717 100644 --- a/daemon/safefileio.go +++ b/daemon/safefileio.go @@ -10,6 +10,10 @@ func ensurePrivateRuntimeDir(path string) error { return safefileio.EnsurePrivateDir(path) } +func validatePrivateRuntimeDir(path string) error { + return safefileio.ValidatePrivateDir(path) +} + func openRuntimeFile(path string) (*os.File, error) { return safefileio.OpenCurrentUserFile(path) } diff --git a/safefileio/private_dir_unix.go b/safefileio/private_dir_unix.go index f5a577f..f59bc96 100644 --- a/safefileio/private_dir_unix.go +++ b/safefileio/private_dir_unix.go @@ -70,3 +70,32 @@ func EnsurePrivateDir(path string) error { } return nil } + +// ValidatePrivateDir verifies path is a non-symlink directory owned by the +// current user with mode 0700. It never creates or chmods the directory. +func ValidatePrivateDir(path string) error { + if path == "" { + return fmt.Errorf("path is empty") + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink", path) + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("stat %s: missing owner information", path) + } + if stat.Uid != uint32(os.Getuid()) { + return fmt.Errorf("%s is not owned by current user", path) + } + if info.Mode().Perm() != 0o700 { + return fmt.Errorf("%s is not mode 0700", path) + } + return nil +} diff --git a/safefileio/private_dir_unix_test.go b/safefileio/private_dir_unix_test.go index a69615d..ff9760a 100644 --- a/safefileio/private_dir_unix_test.go +++ b/safefileio/private_dir_unix_test.go @@ -27,6 +27,29 @@ func TestEnsurePrivateDirRepairsPublicDir(t *testing.T) { require.Equal(t, os.FileMode(0o700), info.Mode().Perm()) } +func TestValidatePrivateDirRejectsWithoutRepairingPublicDir(t *testing.T) { + dir := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-validate-public-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + require.NoError(t, os.RemoveAll(dir)) + require.NoError(t, os.MkdirAll(dir, 0o700)) + require.NoError(t, os.Chmod(dir, 0o777)) + + require.Error(t, safefileio.ValidatePrivateDir(dir)) + + info, err := os.Stat(dir) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o777), info.Mode().Perm()) +} + +func TestValidatePrivateDirAcceptsPrivateDir(t *testing.T) { + dir := filepath.Join("/tmp", fmt.Sprintf("kit-safefileio-validate-private-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + require.NoError(t, os.RemoveAll(dir)) + require.NoError(t, os.MkdirAll(dir, 0o700)) + + require.NoError(t, safefileio.ValidatePrivateDir(dir)) +} + func TestEnsurePrivateDirRejectsEmptyPath(t *testing.T) { require.Error(t, safefileio.EnsurePrivateDir("")) } diff --git a/safefileio/private_dir_windows.go b/safefileio/private_dir_windows.go index a614855..2e23d64 100644 --- a/safefileio/private_dir_windows.go +++ b/safefileio/private_dir_windows.go @@ -53,6 +53,38 @@ func EnsurePrivateDir(path string) error { return restrictWindowsDir(handle, userSID) } +// ValidatePrivateDir verifies path is a non-reparse directory owned by the +// current token user or token owner. It never creates or changes the directory. +func ValidatePrivateDir(path string) error { + if path == "" { + return fmt.Errorf("path is empty") + } + if err := rejectWindowsReparsePoint(path); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + handle, err := openWindowsDir(path) + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(handle) }() + userSID, err := currentWindowsUserSID() + if err != nil { + return err + } + ownerSID, err := currentWindowsOwnerSID() + if err != nil { + return err + } + return verifyWindowsDirHandle(path, handle, userSID, ownerSID) +} + // CurrentUserID returns a stable filesystem-safe identifier for the current // Windows account. func CurrentUserID() (string, error) { diff --git a/safefileio/private_dir_windows_test.go b/safefileio/private_dir_windows_test.go index 3eec675..b8599cf 100644 --- a/safefileio/private_dir_windows_test.go +++ b/safefileio/private_dir_windows_test.go @@ -30,6 +30,13 @@ func TestEnsurePrivateDirCreatesOwnedDirectory(t *testing.T) { require.True(t, owner.Equals(ownerSID)) } +func TestValidatePrivateDirAcceptsPrivateDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "runtime") + require.NoError(t, EnsurePrivateDir(dir)) + + require.NoError(t, ValidatePrivateDir(dir)) +} + func TestEnsurePrivateDirRejectsEmptyPath(t *testing.T) { require.Error(t, EnsurePrivateDir("")) } From da1783dfdb17da77be21c9da440fe72579aa6ce1 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 2 Jun 2026 08:52:20 -0400 Subject: [PATCH 5/6] Validate Windows private directory DACLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation-only private directory checks on Windows must enforce the same trust boundary that EnsurePrivateDir repairs to. Otherwise a current-user-owned directory with broad DACL grants could be accepted as private. ValidatePrivateDir now rejects unprotected DACLs, deny/unknown ACEs, and allowed ACEs for principals outside the current user, token owner, SYSTEM, and Administrators. The Windows test covers a deliberately broadened DACL. Validation: go test ./safefileio; GOOS=windows GOARCH=amd64 go test -c ./safefileio -o /tmp/kit-safefileio-windows.test.exe; go test ./... 🤖 Generated with [OpenAI Codex](https://openai.com/codex) Co-authored-by: OpenAI Codex --- safefileio/private_dir_windows.go | 63 +++++++++++++++++++++++++- safefileio/private_dir_windows_test.go | 28 ++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/safefileio/private_dir_windows.go b/safefileio/private_dir_windows.go index 2e23d64..60b559f 100644 --- a/safefileio/private_dir_windows.go +++ b/safefileio/private_dir_windows.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "os" + "unsafe" "golang.org/x/sys/windows" ) @@ -82,7 +83,10 @@ func ValidatePrivateDir(path string) error { if err != nil { return err } - return verifyWindowsDirHandle(path, handle, userSID, ownerSID) + if err := verifyWindowsDirHandle(path, handle, userSID, ownerSID); err != nil { + return err + } + return verifyWindowsDirDACL(path, handle, userSID, ownerSID) } // CurrentUserID returns a stable filesystem-safe identifier for the current @@ -169,6 +173,63 @@ func verifyWindowsDirHandle(path string, handle windows.Handle, userSID, ownerSI return nil } +func verifyWindowsDirDACL(path string, handle windows.Handle, userSID, ownerSID *windows.SID) error { + descriptor, err := windows.GetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return err + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + if control&windows.SE_DACL_PROTECTED == 0 { + return fmt.Errorf("%s DACL is not protected", path) + } + dacl, _, err := descriptor.DACL() + if err != nil { + return err + } + if dacl == nil { + return fmt.Errorf("%s DACL is empty", path) + } + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return err + } + admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + allowed := []*windows.SID{userSID, ownerSID, system, admins} + for i := uint16(0); i < dacl.AceCount; i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, uint32(i), &ace); err != nil { + return err + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf("%s DACL contains non-allow ACE", path) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !windowsAnyOwnerMatches(sid, allowed) { + return fmt.Errorf("%s DACL grants access to unexpected principal", path) + } + } + return nil +} + +func windowsAnyOwnerMatches(owner *windows.SID, allowed []*windows.SID) bool { + for _, sid := range allowed { + if sid != nil && owner != nil && owner.Equals(sid) { + return true + } + } + return false +} + func restrictWindowsDir(handle windows.Handle, userSID *windows.SID) error { system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) if err != nil { diff --git a/safefileio/private_dir_windows_test.go b/safefileio/private_dir_windows_test.go index b8599cf..4f1d0d4 100644 --- a/safefileio/private_dir_windows_test.go +++ b/safefileio/private_dir_windows_test.go @@ -37,6 +37,34 @@ func TestValidatePrivateDirAcceptsPrivateDir(t *testing.T) { require.NoError(t, ValidatePrivateDir(dir)) } +func TestValidatePrivateDirRejectsBroadDACL(t *testing.T) { + dir := filepath.Join(t.TempDir(), "runtime") + require.NoError(t, EnsurePrivateDir(dir)) + handle, err := openWindowsDir(dir) + require.NoError(t, err) + defer func() { _ = windows.CloseHandle(handle) }() + userSID, err := currentWindowsUserSID() + require.NoError(t, err) + world, err := windows.CreateWellKnownSid(windows.WinWorldSid) + require.NoError(t, err) + acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{ + allowFullControl(userSID, windows.TRUSTEE_IS_USER), + allowFullControl(world, windows.TRUSTEE_IS_WELL_KNOWN_GROUP), + }, nil) + require.NoError(t, err) + require.NoError(t, windows.SetSecurityInfo( + handle, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + acl, + nil, + )) + + require.Error(t, ValidatePrivateDir(dir)) +} + func TestEnsurePrivateDirRejectsEmptyPath(t *testing.T) { require.Error(t, EnsurePrivateDir("")) } From 25b6fd98da73ede9ebcab8ef6b169cdee470ccc5 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Tue, 2 Jun 2026 08:59:52 -0400 Subject: [PATCH 6/6] Use functional options for daemon listen daemon.Listen is a public API meant for callers like kata, so requiring an explicit options struct for normal use makes the common path noisier than it needs to be. The branch is not released yet, so prefer the conventional Go WithXYZ option style before consumers adopt it. This keeps the listen configuration extensible without preserving the clunkier ListenOptions call shape. Validation: go test ./daemon ./safefileio; GOOS=windows GOARCH=amd64 go test -c ./daemon -o /tmp/kit-daemon-windows.test.exe; GOOS=windows GOARCH=amd64 go test -c ./safefileio -o /tmp/kit-safefileio-windows.test.exe; go test ./... Generated with OpenAI Codex Co-authored-by: OpenAI Codex --- daemon/listen.go | 61 ++++++++++++++++++++++++++++---------- daemon/listen_unix_test.go | 28 +++++++---------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/daemon/listen.go b/daemon/listen.go index baeeeec..981dd23 100644 --- a/daemon/listen.go +++ b/daemon/listen.go @@ -13,18 +13,43 @@ import ( const defaultStaleSocketProbeTimeout = 500 * time.Millisecond -// ListenOptions configures Listen. -type ListenOptions struct { +type listenOptions struct { // Store provides the daemon runtime listen lock used to serialize Unix // socket stale cleanup and bind. Ignored when LockPath is set. - Store RuntimeStore + store RuntimeStore // LockPath overrides the lock file used for Unix socket startup. - LockPath string + lockPath string // StaleSocketProbeTimeout bounds the local dial used to prove that an // existing Unix socket path is stale before removing it. - StaleSocketProbeTimeout time.Duration + staleSocketProbeTimeout time.Duration +} + +// ListenOption configures Listen. +type ListenOption func(*listenOptions) + +// WithRuntimeStore uses store's daemon listen lock to serialize Unix socket +// stale cleanup and bind. Ignored when WithListenLockPath is also supplied. +func WithRuntimeStore(store RuntimeStore) ListenOption { + return func(opts *listenOptions) { + opts.store = store + } +} + +// WithListenLockPath overrides the lock file used for Unix socket startup. +func WithListenLockPath(lockPath string) ListenOption { + return func(opts *listenOptions) { + opts.lockPath = lockPath + } +} + +// WithStaleSocketProbeTimeout bounds the local dial used to prove that an +// existing Unix socket path is stale before removing it. +func WithStaleSocketProbeTimeout(timeout time.Duration) ListenOption { + return func(opts *listenOptions) { + opts.staleSocketProbeTimeout = timeout + } } // Listen binds ep for daemon serving. @@ -33,10 +58,14 @@ type ListenOptions struct { // subsequent bind under an inter-process lock. Existing live sockets and // non-socket paths are rejected. TCP endpoints and Windows retain Endpoint's // normal Listen behavior. -func Listen(ctx context.Context, ep Endpoint, opts ListenOptions) (net.Listener, error) { +func Listen(ctx context.Context, ep Endpoint, options ...ListenOption) (net.Listener, error) { if err := ctx.Err(); err != nil { return nil, err } + opts := listenOptions{} + for _, option := range options { + option(&opts) + } if !ep.IsUnix() || runtime.GOOS == "windows" { return ep.Listen() } @@ -71,12 +100,12 @@ func prepareUnixListenEndpoint(ep Endpoint) error { return nil } -func (opts ListenOptions) listenLockPath(ep Endpoint) (string, error) { - if opts.LockPath != "" { - return opts.LockPath, nil +func (opts listenOptions) listenLockPath(ep Endpoint) (string, error) { + if opts.lockPath != "" { + return opts.lockPath, nil } - if opts.Store.Dir != "" { - return opts.Store.ListenLockPath() + if opts.store.Dir != "" { + return opts.store.ListenLockPath() } if ep.Address == "" { return "", fmt.Errorf("empty daemon endpoint address") @@ -84,14 +113,14 @@ func (opts ListenOptions) listenLockPath(ep Endpoint) (string, error) { return ep.Address + ".lock", nil } -func (opts ListenOptions) staleSocketProbeTimeout() time.Duration { - if opts.StaleSocketProbeTimeout > 0 { - return opts.StaleSocketProbeTimeout +func (opts listenOptions) staleProbeTimeout() time.Duration { + if opts.staleSocketProbeTimeout > 0 { + return opts.staleSocketProbeTimeout } return defaultStaleSocketProbeTimeout } -func removeStaleUnixSocket(ctx context.Context, ep Endpoint, opts ListenOptions) error { +func removeStaleUnixSocket(ctx context.Context, ep Endpoint, opts listenOptions) error { if ep.Address == "" { return fmt.Errorf("empty daemon endpoint address") } @@ -105,7 +134,7 @@ func removeStaleUnixSocket(ctx context.Context, ep Endpoint, opts ListenOptions) if info.Mode()&os.ModeSocket == 0 { return fmt.Errorf("refusing to remove non-socket path %s", ep.Address) } - stale, err := unixSocketStale(ctx, ep.Address, opts.staleSocketProbeTimeout()) + stale, err := unixSocketStale(ctx, ep.Address, opts.staleProbeTimeout()) if err != nil { return err } diff --git a/daemon/listen_unix_test.go b/daemon/listen_unix_test.go index e9ce30e..d05d8f6 100644 --- a/daemon/listen_unix_test.go +++ b/daemon/listen_unix_test.go @@ -22,7 +22,7 @@ func TestListenUnixRemovesStaleSocketAndBinds(t *testing.T) { socketPath := staleUnixSocket(t) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + listener, err := daemon.Listen(context.Background(), ep) require.NoError(t, err) t.Cleanup(func() { _ = listener.Close() }) @@ -36,7 +36,7 @@ func TestListenUnixRejectsNonSocketPath(t *testing.T) { require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600)) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + listener, err := daemon.Listen(context.Background(), ep) require.Error(t, err) assert.Nil(t, listener) assert.Contains(t, err.Error(), "refusing to remove non-socket path") @@ -53,7 +53,7 @@ func TestListenUnixRejectsLiveSocket(t *testing.T) { t.Cleanup(func() { _ = live.Close() }) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{}) + listener, err := daemon.Listen(context.Background(), ep) require.Error(t, err) assert.Nil(t, listener) assert.Contains(t, err.Error(), "daemon already listening") @@ -67,7 +67,7 @@ func TestListenUnixSerializesConcurrentStaleSocketStartup(t *testing.T) { socketPath := staleUnixSocket(t) lockPath := filepath.Join(filepath.Dir(socketPath), "daemon.lock") ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - opts := daemon.ListenOptions{LockPath: lockPath} + opt := daemon.WithListenLockPath(lockPath) const starters = 16 start := make(chan struct{}) @@ -75,7 +75,7 @@ func TestListenUnixSerializesConcurrentStaleSocketStartup(t *testing.T) { for range starters { go func() { <-start - listener, err := daemon.Listen(context.Background(), ep, opts) + listener, err := daemon.Listen(context.Background(), ep, opt) results <- listenResult{listener: listener, err: err} }() } @@ -124,7 +124,7 @@ func TestListenUnixProbesAfterAcquiringLock(t *testing.T) { ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} resultCh := make(chan listenResult, 1) go func() { - listener, err := daemon.Listen(ctx, ep, daemon.ListenOptions{LockPath: lockPath}) + listener, err := daemon.Listen(ctx, ep, daemon.WithListenLockPath(lockPath)) resultCh <- listenResult{listener: listener, err: err} }() @@ -156,9 +156,7 @@ func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { require.NoError(t, os.Symlink(target, link)) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ - LockPath: filepath.Join(link, "daemon.lock"), - }) + listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath(filepath.Join(link, "daemon.lock"))) require.Error(t, err) assert.Nil(t, listener) @@ -170,9 +168,7 @@ func TestListenUnixRejectsUnsafeLockDirectory(t *testing.T) { func TestListenUnixRejectsRelativeLockPath(t *testing.T) { ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: unixSocketPath(t)} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ - LockPath: "daemon.lock", - }) + listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath("daemon.lock")) require.Error(t, err) assert.Nil(t, listener) @@ -183,9 +179,7 @@ func TestListenUnixRejectsRelativeLockPath(t *testing.T) { func TestListenUnixRejectsRelativeSocketPath(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "daemon.lock") ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: "daemon.sock"} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ - LockPath: lockPath, - }) + listener, err := daemon.Listen(context.Background(), ep, daemon.WithListenLockPath(lockPath)) require.Error(t, err) assert.Nil(t, listener) @@ -197,9 +191,7 @@ func TestListenUnixRejectsSharedSocketDirectoryEvenWithStoreLock(t *testing.T) { socketPath := filepath.Join("/tmp", "kitd-shared-socket.sock") t.Cleanup(func() { _ = os.Remove(socketPath) }) ep := daemon.Endpoint{Network: daemon.NetworkUnix, Address: socketPath} - listener, err := daemon.Listen(context.Background(), ep, daemon.ListenOptions{ - Store: daemon.RuntimeStore{Dir: t.TempDir()}, - }) + listener, err := daemon.Listen(context.Background(), ep, daemon.WithRuntimeStore(daemon.RuntimeStore{Dir: t.TempDir()})) require.Error(t, err) assert.Nil(t, listener)