From 082f0323657e4a22809a615723ba9cbf95a7a247 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:14:36 +0530 Subject: [PATCH 01/16] fix(daemon): publish status files atomically --- internal/daemon/server.go | 11 +- internal/daemon/status_file.go | 107 +++++++++++ internal/daemon/status_file_test.go | 275 ++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 internal/daemon/status_file.go create mode 100644 internal/daemon/status_file_test.go diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 21c44e0d6..72da0bdfc 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -41,6 +41,10 @@ type ServerOptions struct { Now func() time.Time Log func(string) isAlive func(int) bool // test hook for the single-instance lock + // replaceStatusFile and syncStatusParent are test hooks for the status-file + // commit boundary. nil selects the production filesystem operations. + replaceStatusFile func(src, dst string) error + syncStatusParent func(dir string) error } // NewServer validates options and builds a Server. @@ -208,7 +212,12 @@ func (s *Server) writeStatusFile() error { if err != nil { return err } - if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil { + if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil { + var committed *statusFileCommittedError + if errors.As(err, &committed) { + s.logf("daemon: %v", committed) + return nil + } return fmt.Errorf("daemon: write status file: %w", err) } return nil diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go new file mode 100644 index 000000000..1d8ea82a7 --- /dev/null +++ b/internal/daemon/status_file.go @@ -0,0 +1,107 @@ +package daemon + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/Gitlawb/zero/internal/fsutil" +) + +const statusTempPattern = ".daemon-status-*" + +// statusFileCommittedError reports a warning that happened after the complete +// status document was already published. Callers must not tear down the daemon +// as though publication failed. +type statusFileCommittedError struct { + cause error +} + +func (err *statusFileCommittedError) Error() string { + return fmt.Sprintf("status file publication committed with warning: %v", err.cause) +} + +func (err *statusFileCommittedError) Unwrap() error { + return err.cause +} + +// writeStatusFileAtomically stages a complete, synced sibling file before +// publishing it over path, so it never truncates the live document in place. +// Unix replacement is observer-atomic; Windows uses fsutil's DACL-preserving +// replacement and may briefly leave the path absent, but never exposes partial +// contents. +func writeStatusFileAtomically( + path string, + data []byte, + perm os.FileMode, + replace func(src, dst string) error, + syncParent func(dir string) error, +) error { + dir := filepath.Dir(path) + temp, err := os.CreateTemp(dir, statusTempPattern) + if err != nil { + return fmt.Errorf("create temporary status file: %w", err) + } + tempPath := temp.Name() + closed := false + defer func() { + if !closed { + _ = temp.Close() + } + _ = os.Remove(tempPath) + }() + + if err := temp.Chmod(perm); err != nil { + return fmt.Errorf("set temporary status file permissions: %w", err) + } + if _, err := temp.Write(data); err != nil { + return fmt.Errorf("write temporary status file: %w", err) + } + if err := temp.Sync(); err != nil { + return fmt.Errorf("sync temporary status file: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temporary status file: %w", err) + } + closed = true + + var committedWarning error + if err := fsutil.ReplaceWithRetry(tempPath, path, replace); err != nil { + var committed *fsutil.CommittedReplacementCleanupError + if !errors.As(err, &committed) { + return fmt.Errorf("replace status file: %w", err) + } + committedWarning = fmt.Errorf("clean up replaced status file: %w", err) + } + if syncParent == nil { + syncParent = syncStatusParentDir + } + if err := syncParent(dir); err != nil { + committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err)) + } + if committedWarning != nil { + return &statusFileCommittedError{cause: committedWarning} + } + return nil +} + +// syncStatusParentDir makes the replacement directory entry durable on +// platforms that support syncing directory handles. Windows replacement is +// best-effort durable because Go cannot fsync a directory there. +func syncStatusParentDir(dir string) error { + if runtime.GOOS == "windows" { + return nil + } + directory, err := os.Open(dir) + if err != nil { + return err + } + syncErr := directory.Sync() + closeErr := directory.Close() + if syncErr != nil { + return syncErr + } + return closeErr +} diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go new file mode 100644 index 000000000..3b3d0a2b5 --- /dev/null +++ b/internal/daemon/status_file_test.go @@ -0,0 +1,275 @@ +package daemon + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/fsutil" +) + +func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) { + dir := t.TempDir() + 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(src, dst string) error { + staged, err := os.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 := os.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() + 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(got string) error { + if got != dir { + t.Fatalf("synced parent = %q, want %q", got, dir) + } + 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() + 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(src, dst string) error { + close(replacementReady) + <-publish + return fsutil.ReplaceWithRetry(src, dst, nil) + }, + }, + } + 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 TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) { + dir := t.TempDir() + 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") + var logs []string + parentSynced := false + 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) }, + replaceStatusFile: func(src, dst string) error { + if err := fsutil.ReplaceWithRetry(src, dst, nil); err != nil { + return err + } + return &fsutil.CommittedReplacementCleanupError{ + BackupPath: filepath.Join(dir, ".zero-replace-backup"), + Cause: cleanupErr, + } + }, + syncStatusParent: func(string) 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 parent directory was not synced after committed replacement warning") + } + 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], cleanupErr.Error()) { + t.Fatalf("logs = %q, want committed cleanup warning", logs) + } + assertNoStatusTemps(t, dir) +} + +func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) { + dir := t.TempDir() + 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: 5, + Log: func(message string) { logs = append(logs, message) }, + syncStatusParent: func(string) 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 != 5 { + t.Fatalf("published status = %+v, want version 5", status) + } + if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) { + t.Fatalf("logs = %q, want directory sync warning", logs) + } + assertNoStatusTemps(t, dir) +} + +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) + } +} From 8d0f9182557b6c68e01c79b4f845bf4637add42a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:58:56 +0530 Subject: [PATCH 02/16] fix(daemon): bind status publication to directory handle --- internal/daemon/server.go | 11 +- internal/daemon/server_test.go | 1 + internal/daemon/status_dir_owner_unix.go | 20 +++ internal/daemon/status_dir_owner_windows.go | 12 ++ internal/daemon/status_file.go | 134 +++++++++++++++----- internal/daemon/status_file_test.go | 130 ++++++++++++------- 6 files changed, 226 insertions(+), 82 deletions(-) create mode 100644 internal/daemon/status_dir_owner_unix.go create mode 100644 internal/daemon/status_dir_owner_windows.go diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 72da0bdfc..752d21592 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -41,10 +41,11 @@ type ServerOptions struct { Now func() time.Time Log func(string) isAlive func(int) bool // test hook for the single-instance lock - // replaceStatusFile and syncStatusParent are test hooks for the status-file - // commit boundary. nil selects the production filesystem operations. - replaceStatusFile func(src, dst string) error - syncStatusParent func(dir string) error + // 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 } // NewServer validates options and builds a Server. @@ -212,7 +213,7 @@ func (s *Server) writeStatusFile() error { if err != nil { return err } - if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil { + if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil { var committed *statusFileCommittedError if errors.As(err, &committed) { s.logf("daemon: %v", committed) diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 0fa6c99f7..26b9bf03c 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -11,6 +11,7 @@ import ( 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"), diff --git a/internal/daemon/status_dir_owner_unix.go b/internal/daemon/status_dir_owner_unix.go new file mode 100644 index 000000000..870070b3e --- /dev/null +++ b/internal/daemon/status_dir_owner_unix.go @@ -0,0 +1,20 @@ +//go:build !windows + +package daemon + +import ( + "fmt" + "os" + "syscall" +) + +func checkStatusDirOwner(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return nil + } + if int(stat.Uid) != os.Geteuid() { + return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid) + } + return nil +} diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go new file mode 100644 index 000000000..847784c88 --- /dev/null +++ b/internal/daemon/status_dir_owner_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package daemon + +import "os" + +// Windows has no portable uid to compare. os.Root still binds every operation +// to one directory handle and rejects reparse-point traversal, while the normal +// daemon directory lives below the current user's profile. +func checkStatusDirOwner(os.FileInfo) error { + return nil +} diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index 1d8ea82a7..15e69e86c 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -1,6 +1,8 @@ package daemon import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "os" @@ -10,7 +12,10 @@ import ( "github.com/Gitlawb/zero/internal/fsutil" ) -const statusTempPattern = ".daemon-status-*" +const ( + statusTempPrefix = ".daemon-status-" + statusTempPattern = statusTempPrefix + "*" +) // statusFileCommittedError reports a warning that happened after the complete // status document was already published. Callers must not tear down the daemon @@ -29,28 +34,55 @@ func (err *statusFileCommittedError) Unwrap() error { // writeStatusFileAtomically stages a complete, synced sibling file before // publishing it over path, so it never truncates the live document in place. -// Unix replacement is observer-atomic; Windows uses fsutil's DACL-preserving -// replacement and may briefly leave the path absent, but never exposes partial -// contents. +// All operations after opening the parent use one traversal-resistant Root, so +// swapping a named ancestor cannot redirect replacement or cleanup. func writeStatusFileAtomically( path string, data []byte, perm os.FileMode, - replace func(src, dst string) error, - syncParent func(dir string) error, -) error { + beforeReplace func(), + replace func(root *os.Root, src, dst string) error, + syncParent func(root *os.Root) error, +) (returnErr error) { dir := filepath.Dir(path) - temp, err := os.CreateTemp(dir, statusTempPattern) + root, err := os.OpenRoot(dir) if err != nil { - return fmt.Errorf("create temporary status file: %w", err) + return fmt.Errorf("open status directory: %w", err) + } + committed := false + defer func() { + if err := root.Close(); err != nil { + closeErr := fmt.Errorf("close status directory: %w", err) + if committed { + var committedErr *statusFileCommittedError + if errors.As(returnErr, &committedErr) { + closeErr = errors.Join(committedErr.cause, closeErr) + } + returnErr = &statusFileCommittedError{cause: closeErr} + } else { + returnErr = errors.Join(returnErr, closeErr) + } + } + }() + if err := validateStatusRoot(root); err != nil { + return err + } + + temp, tempName, err := createStatusTemp(root, perm) + if err != nil { + return err } - tempPath := temp.Name() closed := false defer func() { if !closed { - _ = temp.Close() + 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)) } - _ = os.Remove(tempPath) }() if err := temp.Chmod(perm); err != nil { @@ -62,39 +94,77 @@ func writeStatusFileAtomically( if err := temp.Sync(); err != nil { return fmt.Errorf("sync temporary status file: %w", err) } - if err := temp.Close(); err != nil { - return fmt.Errorf("close temporary status file: %w", err) - } + closeErr := temp.Close() closed = true + if closeErr != nil { + return fmt.Errorf("close temporary status file: %w", closeErr) + } + if beforeReplace != nil { + beforeReplace() + } - var committedWarning error - if err := fsutil.ReplaceWithRetry(tempPath, path, replace); err != nil { - var committed *fsutil.CommittedReplacementCleanupError - if !errors.As(err, &committed) { - return fmt.Errorf("replace status file: %w", err) - } - committedWarning = fmt.Errorf("clean up replaced status file: %w", err) + statusName := filepath.Base(path) + rename := root.Rename + if replace != nil { + rename = func(src, dst string) error { return replace(root, src, dst) } } + if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil { + return fmt.Errorf("replace status file: %w", err) + } + committed = true if syncParent == nil { - syncParent = syncStatusParentDir + syncParent = syncStatusRoot + } + if err := syncParent(root); err != nil { + return &statusFileCommittedError{cause: fmt.Errorf("sync status directory: %w", err)} + } + return nil +} + +func validateStatusRoot(root *os.Root) error { + info, err := root.Stat(".") + if err != nil { + return fmt.Errorf("inspect status directory: %w", err) } - if err := syncParent(dir); err != nil { - committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err)) + if !info.IsDir() { + return fmt.Errorf("status directory is not a directory") } - if committedWarning != nil { - return &statusFileCommittedError{cause: committedWarning} + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("status directory permissions are %04o, want owner-only", info.Mode().Perm()) + } + if err := checkStatusDirOwner(info); err != nil { + return err } return nil } -// syncStatusParentDir makes the replacement directory entry durable on -// platforms that support syncing directory handles. Windows replacement is -// best-effort durable because Go cannot fsync a directory there. -func syncStatusParentDir(dir string) error { +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 := os.Open(dir) + directory, err := root.Open(".") if err != nil { return err } diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 3b3d0a2b5..988fc1e1d 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -15,6 +15,7 @@ import ( 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 { @@ -27,8 +28,8 @@ func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) opts: ServerOptions{ Paths: Paths{Status: path}, Version: 2, - replaceStatusFile: func(src, dst string) error { - staged, err := os.ReadFile(src) + replaceStatusFile: func(root *os.Root, src, dst string) error { + staged, err := root.ReadFile(src) if err != nil { t.Fatalf("read staged status: %v", err) } @@ -36,7 +37,7 @@ func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) if err := json.Unmarshal(staged, &decoded); err != nil { t.Fatalf("staged status is incomplete JSON: %v", err) } - current, err := os.ReadFile(dst) + current, err := root.ReadFile(dst) if err != nil { t.Fatalf("read previous status at commit boundary: %v", err) } @@ -64,6 +65,7 @@ func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) 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) @@ -76,9 +78,9 @@ func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) { opts: ServerOptions{ Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path}, Version: 3, - syncStatusParent: func(got string) error { - if got != dir { - t.Fatalf("synced parent = %q, want %q", got, dir) + syncStatusParent: func(root *os.Root) error { + if _, err := root.Stat("."); err != nil { + t.Fatalf("stat bound status parent: %v", err) } parentSynced = true return nil @@ -117,6 +119,7 @@ func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) { func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) { dir := t.TempDir() + secureStatusTestDir(t, dir) path := filepath.Join(dir, "daemon.status") previous := StatusFile{ PID: 7, @@ -140,10 +143,10 @@ func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing. opts: ServerOptions{ Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path}, Version: 2, - replaceStatusFile: func(src, dst string) error { + replaceStatusFile: func(root *os.Root, src, dst string) error { close(replacementReady) <-publish - return fsutil.ReplaceWithRetry(src, dst, nil) + return fsutil.RenameWithRetry(src, dst, root.Rename) }, }, } @@ -170,82 +173,110 @@ func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing. assertNoStatusTemps(t, dir) } -func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) { +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) } - cleanupErr := errors.New("injected backup cleanup failure") + syncErr := errors.New("injected directory sync failure") var logs []string - parentSynced := false 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) }, - replaceStatusFile: func(src, dst string) error { - if err := fsutil.ReplaceWithRetry(src, dst, nil); err != nil { - return err - } - return &fsutil.CommittedReplacementCleanupError{ - BackupPath: filepath.Join(dir, ".zero-replace-backup"), - Cause: cleanupErr, - } - }, - syncStatusParent: func(string) error { - parentSynced = true - return nil - }, + 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) } - if !parentSynced { - t.Fatal("status parent directory was not synced after committed replacement warning") - } 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], cleanupErr.Error()) { - t.Fatalf("logs = %q, want committed cleanup warning", logs) + if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) { + t.Fatalf("logs = %q, want directory sync warning", logs) } assertNoStatusTemps(t, dir) } -func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) { - dir := t.TempDir() +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) + } 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 + substitute := []byte(`{"pid":999,"socket":"substitute"}`) 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) }, - syncStatusParent: func(string) error { return syncErr }, + Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path}, + Version: 5, + beforeStatusReplace: func() { + if err := os.Rename(dir, movedDir); err != nil { + t.Fatalf("move bound status directory: %v", err) + } + 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 returned a post-commit warning as failure: %v", err) + t.Fatalf("writeStatusFile: %v", err) } - status := readStatusDocument(t, path) + 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 != 5 { - t.Fatalf("published status = %+v, want version 5", status) + t.Fatalf("bound status = %+v, want version 5", status) } - if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) { - t.Fatalf("logs = %q, want directory sync warning", logs) + assertNoStatusTemps(t, dir) + assertNoStatusTemps(t, movedDir) +} + +func TestWriteStatusFileRejectsBroadStatusDirectory(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 || !strings.Contains(err.Error(), "want owner-only") { + t.Fatalf("writeStatusFile error = %v, want owner-only directory rejection", err) + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("status file created in broad directory: %v", err) } assertNoStatusTemps(t, dir) } @@ -273,3 +304,12 @@ func assertNoStatusTemps(t *testing.T, dir string) { t.Fatalf("temporary status files remain: %v", matches) } } + +func secureStatusTestDir(t *testing.T, dir string) { + t.Helper() + if runtime.GOOS != "windows" { + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + } +} From c24a63429498ab76b6d6a44b9834aff28b5c8812 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:15:35 +0530 Subject: [PATCH 03/16] fix(daemon): validate status directory access --- internal/daemon/status_dir_owner_unix.go | 4 +- internal/daemon/status_dir_owner_unix_test.go | 44 +++++++ internal/daemon/status_dir_owner_windows.go | 109 +++++++++++++++++- .../daemon/status_dir_owner_windows_test.go | 92 +++++++++++++++ internal/daemon/status_file.go | 14 ++- internal/daemon/status_file_test.go | 85 ++++++++++++-- 6 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 internal/daemon/status_dir_owner_unix_test.go create mode 100644 internal/daemon/status_dir_owner_windows_test.go diff --git a/internal/daemon/status_dir_owner_unix.go b/internal/daemon/status_dir_owner_unix.go index 870070b3e..811524c20 100644 --- a/internal/daemon/status_dir_owner_unix.go +++ b/internal/daemon/status_dir_owner_unix.go @@ -8,10 +8,10 @@ import ( "syscall" ) -func checkStatusDirOwner(info os.FileInfo) error { +func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { - return nil + 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) diff --git a/internal/daemon/status_dir_owner_unix_test.go b/internal/daemon/status_dir_owner_unix_test.go new file mode 100644 index 000000000..316d1797d --- /dev/null +++ b/internal/daemon/status_dir_owner_unix_test.go @@ -0,0 +1,44 @@ +//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 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 } diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index 847784c88..c31f4e830 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -2,11 +2,110 @@ package daemon -import "os" +import ( + "errors" + "fmt" + "os" + "unsafe" -// Windows has no portable uid to compare. os.Root still binds every operation -// to one directory handle and rejects reparse-point traversal, while the normal -// daemon directory lives below the current user's profile. -func checkStatusDirOwner(os.FileInfo) error { + "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") + } + + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("resolve current Windows user: %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) { + return fmt.Errorf("status directory is not owned by the current Windows user") + } + + 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 } + +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)) +} diff --git a/internal/daemon/status_dir_owner_windows_test.go b/internal/daemon/status_dir_owner_windows_test.go new file mode 100644 index 000000000..fe3f5d1d3 --- /dev/null +++ b/internal/daemon/status_dir_owner_windows_test.go @@ -0,0 +1,92 @@ +//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 TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) { + dir := t.TempDir() + secureStatusTestDirPlatform(t, dir) + 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) + } + + 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) + } +} diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index 15e69e86c..474dfca7d 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -108,15 +108,23 @@ func writeStatusFileAtomically( if replace != nil { rename = func(src, dst string) error { return replace(root, src, dst) } } + var committedWarning error if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil { - return fmt.Errorf("replace status file: %w", err) + var committedReplacement *fsutil.CommittedReplacementCleanupError + if !errors.As(err, &committedReplacement) { + return 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 { - return &statusFileCommittedError{cause: fmt.Errorf("sync status directory: %w", err)} + committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err)) + } + if committedWarning != nil { + return &statusFileCommittedError{cause: committedWarning} } return nil } @@ -132,7 +140,7 @@ func validateStatusRoot(root *os.Root) error { if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { return fmt.Errorf("status directory permissions are %04o, want owner-only", info.Mode().Perm()) } - if err := checkStatusDirOwner(info); err != nil { + if err := checkStatusDirOwner(root, info); err != nil { return err } return nil diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 988fc1e1d..1d6374862 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -206,6 +206,55 @@ func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) { 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 TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) { parent := t.TempDir() dir := filepath.Join(parent, "live") @@ -213,20 +262,26 @@ func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) { 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: 5, + Version: 6, beforeStatusReplace: func() { - if err := os.Rename(dir, movedDir); err != nil { - t.Fatalf("move bound status directory: %v", err) + 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) @@ -241,14 +296,28 @@ func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) { 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 != 5 { - t.Fatalf("bound status = %+v, want version 5", status) + if status.Version != 6 { + t.Fatalf("bound status = %+v, want version 6", status) } assertNoStatusTemps(t, dir) assertNoStatusTemps(t, movedDir) @@ -307,9 +376,5 @@ func assertNoStatusTemps(t *testing.T, dir string) { func secureStatusTestDir(t *testing.T, dir string) { t.Helper() - if runtime.GOOS != "windows" { - if err := os.Chmod(dir, 0o700); err != nil { - t.Fatal(err) - } - } + secureStatusTestDirPlatform(t, dir) } From f0103fd147c35b25aa0028b85ea4bd24ee190805 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:32:45 +0530 Subject: [PATCH 04/16] fix(daemon): accept current token directory owner --- internal/daemon/status_dir_owner_windows.go | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index c31f4e830..cb2878bb2 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -60,16 +60,21 @@ func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) { return fmt.Errorf("status directory security descriptor is unavailable") } - user, err := windows.GetCurrentProcessToken().GetTokenUser() + 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) { - return fmt.Errorf("status directory is not owned by the current Windows user") + 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() @@ -102,6 +107,27 @@ func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) { 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) || From b0c83e1ebcce1016f636056945b65df12fff2bde Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:58:33 +0530 Subject: [PATCH 05/16] fix(daemon): migrate owned runtime directories safely --- internal/daemon/server.go | 2 +- internal/daemon/server_test.go | 67 +++++++++++++- internal/daemon/socket.go | 52 ++++++++++- internal/daemon/status_dir_owner_unix.go | 24 +++++ internal/daemon/status_dir_owner_unix_test.go | 7 ++ internal/daemon/status_dir_owner_windows.go | 90 +++++++++++++++++++ .../daemon/status_dir_owner_windows_test.go | 11 ++- internal/daemon/status_file.go | 18 ++++ internal/daemon/status_file_test.go | 17 ++++ internal/observability/crash.go | 2 +- internal/observability/crash_test.go | 28 ++++++ 11 files changed, 308 insertions(+), 10 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 752d21592..d5fb33495 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -85,7 +85,7 @@ 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 { + if err := secureRuntimeParents(s.opts.Paths); err != nil { return err } lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive) diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 26b9bf03c..312b3bdaf 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/Gitlawb/zero/internal/observability" ) func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) { @@ -17,6 +19,11 @@ func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) { 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) @@ -29,7 +36,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) { @@ -137,6 +144,64 @@ func TestServerEndToEnd(t *testing.T) { } } +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) diff --git a/internal/daemon/socket.go b/internal/daemon/socket.go index 2427f27d3..4777e69af 100644 --- a/internal/daemon/socket.go +++ b/internal/daemon/socket.go @@ -1,6 +1,7 @@ package daemon import ( + "errors" "fmt" "os" "path/filepath" @@ -12,10 +13,53 @@ 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) +// secureRuntimeParents creates and hardens every directory that can influence +// daemon coordination. Existing directories are migrated only after ownership +// is verified through a bound handle; directories owned by another user fail +// closed. +func secureRuntimeParents(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) + } + if err := secureRuntimeDirectory(absolute); err != nil { + return fmt.Errorf("daemon: secure %s directory: %w", parent.name, err) + } + } + return nil +} + +func secureRuntimeDirectory(path string) (returnErr error) { + root, err := os.OpenRoot(path) + if err != nil { + return fmt.Errorf("open runtime directory: %w", err) + } + defer func() { + if err := root.Close(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close runtime directory: %w", err)) + } + }() + if err := secureStatusRoot(root); err != nil { + return err + } + return nil } // checkSocketPathLength rejects an over-long unix socket path before bind. diff --git a/internal/daemon/status_dir_owner_unix.go b/internal/daemon/status_dir_owner_unix.go index 811524c20..1a5478f34 100644 --- a/internal/daemon/status_dir_owner_unix.go +++ b/internal/daemon/status_dir_owner_unix.go @@ -3,6 +3,7 @@ package daemon import ( + "errors" "fmt" "os" "syscall" @@ -18,3 +19,26 @@ func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error { } return nil } + +func hardenStatusDir(root *os.Root) (returnErr error) { + directory, err := root.Open(".") + if err != nil { + return fmt.Errorf("open status directory for hardening: %w", err) + } + defer func() { + if err := directory.Close(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close status directory hardening handle: %w", err)) + } + }() + info, err := directory.Stat() + if err != nil { + return fmt.Errorf("inspect status directory ownership before hardening: %w", err) + } + if err := checkStatusDirOwner(root, info); err != nil { + return err + } + if err := directory.Chmod(0o700); err != nil { + return fmt.Errorf("harden status directory permissions: %w", err) + } + return nil +} diff --git a/internal/daemon/status_dir_owner_unix_test.go b/internal/daemon/status_dir_owner_unix_test.go index 316d1797d..eb8579f09 100644 --- a/internal/daemon/status_dir_owner_unix_test.go +++ b/internal/daemon/status_dir_owner_unix_test.go @@ -17,6 +17,13 @@ func secureStatusTestDirPlatform(t *testing.T, dir string) { } } +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") { diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index cb2878bb2..668ee86c4 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -24,6 +24,8 @@ const statusDirectoryWriteAccess = windows.ACCESS_MASK( 0x40, // FILE_DELETE_CHILD ) +var statusReOpenFile = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReOpenFile") + // 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. @@ -107,6 +109,94 @@ func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) { return nil } +func hardenStatusDir(root *os.Root) (returnErr error) { + directory, err := root.Open(".") + if err != nil { + return fmt.Errorf("open status directory for hardening: %w", err) + } + defer func() { + if err := directory.Close(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close status directory hardening handle: %w", err)) + } + }() + + raw, err := directory.SyscallConn() + if err != nil { + return fmt.Errorf("access status directory hardening handle: %w", err) + } + var securityHandle windows.Handle + var reopenErr error + if err := raw.Control(func(rawHandle uintptr) { + reopened, _, callErr := statusReOpenFile.Call( + rawHandle, + uintptr(windows.READ_CONTROL|windows.WRITE_DAC), + uintptr(windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE), + uintptr(windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS), + ) + securityHandle = windows.Handle(reopened) + if securityHandle == windows.InvalidHandle { + reopenErr = callErr + } + }); err != nil { + return fmt.Errorf("reopen status directory hardening handle: %w", err) + } + if reopenErr != nil { + return fmt.Errorf("reopen status directory with security access: %w", reopenErr) + } + if securityHandle == 0 || securityHandle == windows.InvalidHandle { + return fmt.Errorf("reopen status directory with security access: invalid handle") + } + defer func() { + if err := windows.CloseHandle(securityHandle); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close status 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 := currentWindowsTokenOwner(token) + if err != nil { + return fmt.Errorf("resolve current Windows token owner: %w", err) + } + 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 status directory DACL: %w", err) + } + dacl, _, err := desired.DACL() + if err != nil { + return fmt.Errorf("read private status directory DACL: %w", err) + } + + current, err := windows.GetSecurityInfo(securityHandle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read status directory owner before hardening: %w", err) + } + owner, _, err := current.Owner() + if err != nil { + return fmt.Errorf("read status directory owner before hardening: %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") + } + 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 status directory DACL: %w", err) + } + return nil +} + type statusDirectoryTokenOwner struct { owner *windows.SID } diff --git a/internal/daemon/status_dir_owner_windows_test.go b/internal/daemon/status_dir_owner_windows_test.go index fe3f5d1d3..ff7f0661b 100644 --- a/internal/daemon/status_dir_owner_windows_test.go +++ b/internal/daemon/status_dir_owner_windows_test.go @@ -41,9 +41,8 @@ func secureStatusTestDirPlatform(t *testing.T, dir string) { } } -func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) { - dir := t.TempDir() - secureStatusTestDirPlatform(t, dir) +func broadenStatusTestDirPlatform(t *testing.T, dir string) { + t.Helper() worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid) if err != nil { t.Fatal(err) @@ -75,6 +74,12 @@ func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) { ); 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 { diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index 474dfca7d..ecc91ed13 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -146,6 +146,24 @@ func validateStatusRoot(root *os.Root) error { return nil } +// secureStatusRoot migrates a current-user-owned runtime directory to the +// owner-only invariant. hardenStatusDir performs both its ownership proof and +// mutation through a handle beneath root; validation then independently checks +// the resulting invariant before the directory can be used. +func secureStatusRoot(root *os.Root) error { + info, err := root.Stat(".") + if err != nil { + return fmt.Errorf("inspect status directory before hardening: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("status directory is not a directory") + } + if err := hardenStatusDir(root); err != nil { + return err + } + return validateStatusRoot(root) +} + func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) { for range 100 { var suffix [16]byte diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 1d6374862..081d90bf7 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -350,6 +350,23 @@ func TestWriteStatusFileRejectsBroadStatusDirectory(t *testing.T) { assertNoStatusTemps(t, dir) } +func TestSecureStatusRootHardensBroadCurrentUserDirectory(t *testing.T) { + dir := t.TempDir() + broadenStatusTestDirPlatform(t, dir) + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + if err := secureStatusRoot(root); err != nil { + t.Fatalf("secureStatusRoot: %v", err) + } + 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) diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 9017700d2..34db77188 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -26,7 +26,7 @@ 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 { + if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log") diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index 8e97330bf..f5389f381 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -3,6 +3,8 @@ package observability import ( "bytes" "os" + "path/filepath" + "runtime" "strings" "testing" "time" @@ -27,6 +29,32 @@ func TestWriteAndFormatCrashReport(t *testing.T) { } } +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 TestRecoverCapturesPanic(t *testing.T) { dir := t.TempDir() var stderr bytes.Buffer From f3148332889e82e7959aba69bd4d9a458e1be246 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:08:57 +0530 Subject: [PATCH 06/16] fix(daemon): open Windows security handle relatively --- internal/daemon/status_dir_owner_windows.go | 47 ++++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index 668ee86c4..9ecb16b16 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -24,8 +24,6 @@ const statusDirectoryWriteAccess = windows.ACCESS_MASK( 0x40, // FILE_DELETE_CHILD ) -var statusReOpenFile = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReOpenFile") - // 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. @@ -124,27 +122,44 @@ func hardenStatusDir(root *os.Root) (returnErr error) { if err != nil { return fmt.Errorf("access status directory hardening handle: %w", err) } + // Root.Open uses NtCreateFile internally, so its handle cannot be passed to + // ReOpenFile (which requires a CreateFile handle). Open "." relative to the + // bound handle instead, requesting the security rights needed for the DACL + // update without resolving the directory by path again. var securityHandle windows.Handle - var reopenErr error + var openErr error if err := raw.Control(func(rawHandle uintptr) { - reopened, _, callErr := statusReOpenFile.Call( - rawHandle, - uintptr(windows.READ_CONTROL|windows.WRITE_DAC), - uintptr(windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE), - uintptr(windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS), - ) - securityHandle = windows.Handle(reopened) - if securityHandle == windows.InvalidHandle { - reopenErr = callErr + 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("reopen status directory hardening handle: %w", err) + return fmt.Errorf("open status directory hardening handle: %w", err) } - if reopenErr != nil { - return fmt.Errorf("reopen status directory with security access: %w", reopenErr) + if openErr != nil { + return fmt.Errorf("open status directory with security access: %w", openErr) } if securityHandle == 0 || securityHandle == windows.InvalidHandle { - return fmt.Errorf("reopen status directory with security access: invalid handle") + return fmt.Errorf("open status directory with security access: invalid handle") } defer func() { if err := windows.CloseHandle(securityHandle); err != nil { From bfbf7d22c87f1c5c1d6149bbafb72983d7affd2b Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:18:49 +0530 Subject: [PATCH 07/16] fix(daemon): use current NT directory object --- internal/daemon/status_dir_owner_windows.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index 9ecb16b16..e4d65ebd1 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -123,13 +123,14 @@ func hardenStatusDir(root *os.Root) (returnErr error) { return fmt.Errorf("access status directory hardening handle: %w", err) } // Root.Open uses NtCreateFile internally, so its handle cannot be passed to - // ReOpenFile (which requires a CreateFile handle). Open "." relative to the - // bound handle instead, requesting the security rights needed for the DACL - // update without resolving the directory by path again. + // ReOpenFile (which requires a CreateFile handle). Reopen the bound directory + // with an empty relative NT object name (the NT representation Go uses for + // "."), requesting the security rights needed for the DACL update without + // resolving the directory by path again. var securityHandle windows.Handle var openErr error if err := raw.Control(func(rawHandle uintptr) { - objectName, nameErr := windows.NewNTUnicodeString(".") + objectName, nameErr := windows.NewNTUnicodeString("") if nameErr != nil { openErr = nameErr return From 33ada0fcca807a4a7152fcbbbdf041dd6b07e782 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:37:25 +0530 Subject: [PATCH 08/16] fix(observability): harden existing crash directories --- internal/daemon/socket.go | 25 +--- internal/daemon/status_dir_owner_unix.go | 24 ---- internal/daemon/status_dir_owner_windows.go | 106 --------------- internal/daemon/status_file.go | 18 --- internal/daemon/status_file_test.go | 9 +- internal/observability/crash.go | 22 +++- internal/observability/crash_test.go | 31 +++++ internal/privatedir/privatedir.go | 36 ++++++ internal/privatedir/privatedir_unix.go | 47 +++++++ internal/privatedir/privatedir_windows.go | 135 ++++++++++++++++++++ 10 files changed, 278 insertions(+), 175 deletions(-) create mode 100644 internal/privatedir/privatedir.go create mode 100644 internal/privatedir/privatedir_unix.go create mode 100644 internal/privatedir/privatedir_windows.go diff --git a/internal/daemon/socket.go b/internal/daemon/socket.go index 4777e69af..78f0474fe 100644 --- a/internal/daemon/socket.go +++ b/internal/daemon/socket.go @@ -1,10 +1,10 @@ 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 @@ -36,32 +36,13 @@ func secureRuntimeParents(paths Paths) error { continue } seen[absolute] = struct{}{} - if err := os.MkdirAll(absolute, 0o700); err != nil { - return fmt.Errorf("daemon: create %s directory: %w", parent.name, err) - } - if err := secureRuntimeDirectory(absolute); err != nil { + if err := privatedir.Ensure(absolute); err != nil { return fmt.Errorf("daemon: secure %s directory: %w", parent.name, err) } } return nil } -func secureRuntimeDirectory(path string) (returnErr error) { - root, err := os.OpenRoot(path) - if err != nil { - return fmt.Errorf("open runtime directory: %w", err) - } - defer func() { - if err := root.Close(); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close runtime directory: %w", err)) - } - }() - if err := secureStatusRoot(root); err != nil { - return err - } - return nil -} - // checkSocketPathLength rejects an over-long unix socket path before bind. func checkSocketPathLength(socketPath string) error { if len(socketPath) > maxUnixSocketPath { diff --git a/internal/daemon/status_dir_owner_unix.go b/internal/daemon/status_dir_owner_unix.go index 1a5478f34..811524c20 100644 --- a/internal/daemon/status_dir_owner_unix.go +++ b/internal/daemon/status_dir_owner_unix.go @@ -3,7 +3,6 @@ package daemon import ( - "errors" "fmt" "os" "syscall" @@ -19,26 +18,3 @@ func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error { } return nil } - -func hardenStatusDir(root *os.Root) (returnErr error) { - directory, err := root.Open(".") - if err != nil { - return fmt.Errorf("open status directory for hardening: %w", err) - } - defer func() { - if err := directory.Close(); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close status directory hardening handle: %w", err)) - } - }() - info, err := directory.Stat() - if err != nil { - return fmt.Errorf("inspect status directory ownership before hardening: %w", err) - } - if err := checkStatusDirOwner(root, info); err != nil { - return err - } - if err := directory.Chmod(0o700); err != nil { - return fmt.Errorf("harden status directory permissions: %w", err) - } - return nil -} diff --git a/internal/daemon/status_dir_owner_windows.go b/internal/daemon/status_dir_owner_windows.go index e4d65ebd1..cb2878bb2 100644 --- a/internal/daemon/status_dir_owner_windows.go +++ b/internal/daemon/status_dir_owner_windows.go @@ -107,112 +107,6 @@ func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) { return nil } -func hardenStatusDir(root *os.Root) (returnErr error) { - directory, err := root.Open(".") - if err != nil { - return fmt.Errorf("open status directory for hardening: %w", err) - } - defer func() { - if err := directory.Close(); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close status directory hardening handle: %w", err)) - } - }() - - raw, err := directory.SyscallConn() - if err != nil { - return fmt.Errorf("access status directory hardening handle: %w", err) - } - // Root.Open uses NtCreateFile internally, so its handle cannot be passed to - // ReOpenFile (which requires a CreateFile handle). Reopen the bound directory - // with an empty relative NT object name (the NT representation Go uses for - // "."), requesting the security rights needed for the DACL update without - // resolving the directory by path again. - 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 status directory hardening handle: %w", err) - } - if openErr != nil { - return fmt.Errorf("open status directory with security access: %w", openErr) - } - if securityHandle == 0 || securityHandle == windows.InvalidHandle { - return fmt.Errorf("open status directory with security access: invalid handle") - } - defer func() { - if err := windows.CloseHandle(securityHandle); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close status 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 := currentWindowsTokenOwner(token) - if err != nil { - return fmt.Errorf("resolve current Windows token owner: %w", err) - } - 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 status directory DACL: %w", err) - } - dacl, _, err := desired.DACL() - if err != nil { - return fmt.Errorf("read private status directory DACL: %w", err) - } - - current, err := windows.GetSecurityInfo(securityHandle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) - if err != nil { - return fmt.Errorf("read status directory owner before hardening: %w", err) - } - owner, _, err := current.Owner() - if err != nil { - return fmt.Errorf("read status directory owner before hardening: %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") - } - 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 status directory DACL: %w", err) - } - return nil -} - type statusDirectoryTokenOwner struct { owner *windows.SID } diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index ecc91ed13..474dfca7d 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -146,24 +146,6 @@ func validateStatusRoot(root *os.Root) error { return nil } -// secureStatusRoot migrates a current-user-owned runtime directory to the -// owner-only invariant. hardenStatusDir performs both its ownership proof and -// mutation through a handle beneath root; validation then independently checks -// the resulting invariant before the directory can be used. -func secureStatusRoot(root *os.Root) error { - info, err := root.Stat(".") - if err != nil { - return fmt.Errorf("inspect status directory before hardening: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("status directory is not a directory") - } - if err := hardenStatusDir(root); err != nil { - return err - } - return validateStatusRoot(root) -} - func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) { for range 100 { var suffix [16]byte diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 081d90bf7..061cd2f5d 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Gitlawb/zero/internal/fsutil" + "github.com/Gitlawb/zero/internal/privatedir" ) func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) { @@ -350,18 +351,18 @@ func TestWriteStatusFileRejectsBroadStatusDirectory(t *testing.T) { assertNoStatusTemps(t, dir) } -func TestSecureStatusRootHardensBroadCurrentUserDirectory(t *testing.T) { +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 := secureStatusRoot(root); err != nil { - t.Fatalf("secureStatusRoot: %v", err) - } if err := validateStatusRoot(root); err != nil { t.Fatalf("validate hardened status root: %v", err) } diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 34db77188..2cc697a68 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -13,6 +13,8 @@ import ( "path/filepath" "runtime/debug" "time" + + "github.com/Gitlawb/zero/internal/privatedir" ) // crashExitCode is returned when a top-level panic is recovered. @@ -26,7 +28,7 @@ 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, 0o700); err != nil { + if err := ensureCrashDirectory(dir); err != nil { return "", err } path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log") @@ -36,6 +38,24 @@ func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti return path, nil } +func ensureCrashDirectory(dir string) 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 fmt.Errorf("secure crash report parent: %w", err) + } + } + if err := privatedir.Ensure(clean); err != nil { + return fmt.Errorf("secure crash report directory: %w", err) + } + return nil +} + // DefaultCrashDir is where crash reports are written by default. func DefaultCrashDir() string { if home, err := os.UserHomeDir(); err == nil && home != "" { diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index f5389f381..7e874bd33 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -55,6 +55,37 @@ func TestWriteCrashReportCreatesPrivateDefaultDirectories(t *testing.T) { } } +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 TestRecoverCapturesPanic(t *testing.T) { dir := t.TempDir() var stderr bytes.Buffer diff --git a/internal/privatedir/privatedir.go b/internal/privatedir/privatedir.go new file mode 100644 index 000000000..835aeb77e --- /dev/null +++ b/internal/privatedir/privatedir.go @@ -0,0 +1,36 @@ +// 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) (returnErr error) { + absolute, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve private directory: %w", err) + } + if err := os.MkdirAll(absolute, 0o700); err != nil { + return fmt.Errorf("create private directory: %w", err) + } + root, err := os.OpenRoot(absolute) + if err != nil { + return fmt.Errorf("open private directory: %w", err) + } + defer func() { + if err := root.Close(); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close private directory: %w", err)) + } + }() + if err := harden(root); err != nil { + return err + } + return nil +} diff --git a/internal/privatedir/privatedir_unix.go b/internal/privatedir/privatedir_unix.go new file mode 100644 index 000000000..568e8fd02 --- /dev/null +++ b/internal/privatedir/privatedir_unix.go @@ -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 +} diff --git a/internal/privatedir/privatedir_windows.go b/internal/privatedir/privatedir_windows.go new file mode 100644 index 000000000..39358b455 --- /dev/null +++ b/internal/privatedir/privatedir_windows.go @@ -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() +} From 2ff6b0727191a51b1460534db2ddafef5569f8af Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:52:40 +0530 Subject: [PATCH 09/16] fix(security): bind crash report creation to private root --- internal/daemon/status_file.go | 17 ++++----- internal/daemon/status_file_test.go | 55 ++++++++++++++++++++++++++++ internal/observability/crash.go | 47 +++++++++++++++++++----- internal/observability/crash_test.go | 46 +++++++++++++++++++++++ internal/privatedir/privatedir.go | 34 +++++++++++------ 5 files changed, 169 insertions(+), 30 deletions(-) diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index 474dfca7d..345f00cb2 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -52,15 +52,12 @@ func writeStatusFileAtomically( committed := false defer func() { if err := root.Close(); err != nil { - closeErr := fmt.Errorf("close status directory: %w", err) - if committed { - var committedErr *statusFileCommittedError - if errors.As(returnErr, &committedErr) { - closeErr = errors.Join(committedErr.cause, closeErr) - } - returnErr = &statusFileCommittedError{cause: closeErr} - } else { - returnErr = errors.Join(returnErr, closeErr) + returnErr = errors.Join(returnErr, fmt.Errorf("close status directory: %w", err)) + } + if committed && returnErr != nil { + var committedErr *statusFileCommittedError + if !errors.As(returnErr, &committedErr) { + returnErr = &statusFileCommittedError{cause: returnErr} } } }() @@ -124,7 +121,7 @@ func writeStatusFileAtomically( committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err)) } if committedWarning != nil { - return &statusFileCommittedError{cause: committedWarning} + return committedWarning } return nil } diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 061cd2f5d..612d0352c 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -256,6 +256,61 @@ func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) 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") diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 2cc697a68..2ea6ec83e 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -7,6 +7,7 @@ package observability import ( + "errors" "fmt" "io" "os" @@ -28,17 +29,44 @@ 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 := ensureCrashDirectory(dir); err != nil { + return writeCrashReport(dir, label, recovered, stack, ts, nil) +} + +func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, beforeCreate func()) (path string, returnErr error) { + 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 { - return "", err + defer func() { + if err := root.Close(); err != nil { + path = "" + returnErr = errors.Join(returnErr, fmt.Errorf("close crash report directory: %w", err)) + } + }() + + name := "crash-" + ts.UTC().Format("20060102-150405") + ".log" + path = filepath.Join(dir, name) + if beforeCreate != nil { + beforeCreate() + } + report, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", fmt.Errorf("create crash report: %w", err) + } + if _, err := report.Write([]byte(FormatCrashReport(label, recovered, stack, ts))); 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.Close(); err != nil { + return "", fmt.Errorf("close crash report: %w", err) } return path, nil } -func ensureCrashDirectory(dir string) error { +func openCrashDirectory(dir string) (*os.Root, error) { clean := filepath.Clean(dir) defaultDir := filepath.Clean(DefaultCrashDir()) parent := filepath.Dir(clean) @@ -47,13 +75,14 @@ func ensureCrashDirectory(dir string) error { // 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 fmt.Errorf("secure crash report parent: %w", err) + return nil, fmt.Errorf("secure crash report parent: %w", err) } } - if err := privatedir.Ensure(clean); err != nil { - return fmt.Errorf("secure crash report directory: %w", err) + root, err := privatedir.Open(clean) + if err != nil { + return nil, fmt.Errorf("secure crash report directory: %w", err) } - return nil + return root, nil } // DefaultCrashDir is where crash reports are written by default. diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index 7e874bd33..0e3e7fe8c 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -2,6 +2,7 @@ package observability import ( "bytes" + "errors" "os" "path/filepath" "runtime" @@ -86,6 +87,51 @@ func TestWriteCrashReportHardensPreexistingDefaultDirectories(t *testing.T) { } } +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, 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 + } + + reportName := filepath.Base(path) + 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 TestRecoverCapturesPanic(t *testing.T) { dir := t.TempDir() var stderr bytes.Buffer diff --git a/internal/privatedir/privatedir.go b/internal/privatedir/privatedir.go index 835aeb77e..16114af15 100644 --- a/internal/privatedir/privatedir.go +++ b/internal/privatedir/privatedir.go @@ -12,25 +12,37 @@ import ( // 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) (returnErr error) { +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 fmt.Errorf("resolve private directory: %w", err) + return nil, fmt.Errorf("resolve private directory: %w", err) } if err := os.MkdirAll(absolute, 0o700); err != nil { - return fmt.Errorf("create private directory: %w", err) + return nil, fmt.Errorf("create private directory: %w", err) } root, err := os.OpenRoot(absolute) if err != nil { - return fmt.Errorf("open private directory: %w", err) + return nil, fmt.Errorf("open private directory: %w", err) } - defer func() { - if err := root.Close(); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close private directory: %w", err)) - } - }() if err := harden(root); err != nil { - return err + closeErr := root.Close() + if closeErr != nil { + return nil, errors.Join(err, fmt.Errorf("close private directory: %w", closeErr)) + } + return nil, err } - return nil + return root, nil } From 6e7f9c4ca992ad6e16f06f86af1e662f6d33cd02 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:55 +0530 Subject: [PATCH 10/16] fix(observability): atomically publish crash reports --- internal/observability/crash.go | 99 ++++++++++++++++++++--- internal/observability/crash_test.go | 112 ++++++++++++++++++++++++--- 2 files changed, 193 insertions(+), 18 deletions(-) diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 2ea6ec83e..3471beb9b 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -7,6 +7,8 @@ package observability import ( + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -21,6 +23,13 @@ import ( // crashExitCode is returned when a top-level panic is recovered. const crashExitCode = 1 +const crashTempPrefix = ".crash-report-" + +type crashReportHooks struct { + beforePublish func() + write func(*os.File, []byte) (int, 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", @@ -29,10 +38,14 @@ 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) { - return writeCrashReport(dir, label, recovered, stack, ts, nil) + return writeCrashReport(dir, label, recovered, stack, ts, crashReportHooks{}) } -func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Time, beforeCreate func()) (path string, returnErr error) { +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 @@ -46,26 +59,90 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti name := "crash-" + ts.UTC().Format("20060102-150405") + ".log" path = filepath.Join(dir, name) - if beforeCreate != nil { - beforeCreate() - } - report, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + report, tempName, err := createCrashTemp(root) if err != nil { - return "", fmt.Errorf("create crash report: %w", err) + return "", err + } + defer func() { + if tempName == "" { + return + } + if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) { + path = "" + 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) } } - if _, err := report.Write([]byte(FormatCrashReport(label, recovered, stack, ts))); err != nil { + 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() + } + if err := root.Link(tempName, name); err != nil { + return "", fmt.Errorf("publish crash report: %w", err) + } + if err := root.Remove(tempName); err != nil { + return "", fmt.Errorf("remove temporary crash report: %w", err) + } + tempName = "" + if !crashPathUsesRoot(root, absoluteDir) { + return "", nil + } return path, nil } +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()) @@ -104,7 +181,11 @@ func Recover(dir, label string, stderr io.Writer, code *int) { } 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) + 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) + } } else { fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack) } diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index 0e3e7fe8c..4c9e52e24 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -97,14 +97,16 @@ func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) { 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, 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) - } + 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) @@ -119,7 +121,10 @@ func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) { return } - reportName := filepath.Base(path) + 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) @@ -132,6 +137,95 @@ func TestWriteCrashReportBindsDirectoryDuringSwap(t *testing.T) { } } +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) + } + if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts); !errors.Is(err, os.ErrExist) { + t.Fatalf("WriteCrashReport error = %v, want existing destination", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "existing" { + t.Fatalf("existing report overwritten: %q", data) + } +} + +type crashWriteResult struct { + path string + err error +} + func TestRecoverCapturesPanic(t *testing.T) { dir := t.TempDir() var stderr bytes.Buffer From 8549347bddeacfd97d632ad18c3ed31ab3ba069a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:17:53 +0530 Subject: [PATCH 11/16] fix(observability): preserve committed crash reports --- internal/observability/crash.go | 110 ++++++++++++++++++++++++--- internal/observability/crash_test.go | 99 ++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 9 deletions(-) diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 3471beb9b..52bc4e85c 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -15,6 +15,7 @@ import ( "os" "path/filepath" "runtime/debug" + "strings" "time" "github.com/Gitlawb/zero/internal/privatedir" @@ -25,9 +26,28 @@ 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 + remove func(*os.Root, string) error } // FormatCrashReport renders a human-readable crash report. @@ -50,11 +70,17 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti if err != nil { return "", err } + committed := false defer func() { if err := root.Close(); err != nil { - path = "" 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" @@ -67,8 +93,7 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti if tempName == "" { return } - if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) { - path = "" + 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)) } }() @@ -102,19 +127,76 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti if hooks.beforePublish != nil { hooks.beforePublish() } - if err := root.Link(tempName, name); err != nil { - return "", fmt.Errorf("publish crash report: %w", err) + publishName := name + link := root.Link + if hooks.link != nil { + link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) } } - if err := root.Remove(tempName); err != nil { - return "", fmt.Errorf("remove temporary crash report: %w", err) + if linkErr := link(tempName, name); linkErr != nil { + if errors.Is(linkErr, os.ErrExist) { + return "", fmt.Errorf("publish crash report: %w", linkErr) + } + publishName, err = fallbackCrashName(root, name, tempName) + if err != nil { + return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), err) + } + if err := root.Rename(tempName, publishName); err != nil { + return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), fmt.Errorf("publish crash report with atomic rename: %w", err)) + } + committed = true + tempName = "" + } else { + committed = true + 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 = "" } - tempName = "" + path = filepath.Join(dir, publishName) if !crashPathUsesRoot(root, absoluteDir) { return "", nil } return path, nil } +// fallbackCrashName selects an unpredictable vacant name for filesystems that +// cannot create hard links. Rename then publishes the already-synced staging +// file atomically without replacing the timestamp-only name or any other +// crash report that was present when the fallback name was selected. The +// directory is private to the current user, which excludes cross-user races. +func fallbackCrashName(root *os.Root, timestampName, tempName string) (string, error) { + base := strings.TrimSuffix(timestampName, filepath.Ext(timestampName)) + if suffix := strings.TrimPrefix(tempName, crashTempPrefix); suffix != "" && suffix != tempName { + candidate := base + "-" + suffix + filepath.Ext(timestampName) + if _, err := root.Lstat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate, nil + } else if err != nil { + return "", fmt.Errorf("inspect fallback crash report path: %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 := root.Lstat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate, nil + } else if err != nil { + return "", fmt.Errorf("inspect fallback crash report path: %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 @@ -180,12 +262,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 { + 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) } diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index 4c9e52e24..c09bbff78 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -219,6 +219,83 @@ func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) { 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) != 1 { + t.Fatalf("crash directory contains temporary files after publish failure: %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 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) + } } type crashWriteResult struct { @@ -262,3 +339,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) + } +} From ca19e2fb3b1c86e7a1bb7a7a8e66452cdc71322a Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:56:38 +0530 Subject: [PATCH 12/16] fix(observability): revalidate committed crash path --- internal/observability/crash.go | 10 ++++--- internal/observability/crash_test.go | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 52bc4e85c..dd83897b9 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -147,16 +147,18 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti 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 = "" } - path = filepath.Join(dir, publishName) - if !crashPathUsesRoot(root, absoluteDir) { - return "", nil - } return path, nil } diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index c09bbff78..2688605d3 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -251,6 +251,49 @@ func TestWriteCrashReportPreservesPathAfterCommittedCleanupWarning(t *testing.T) } } +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) From a4c3cc9c8a983f1277d379a309811c316b6a68c0 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:54:36 +0530 Subject: [PATCH 13/16] fix(daemon): secure runtime lifecycle boundaries --- internal/cli/daemon.go | 7 +- internal/daemon/server_test.go | 93 ++++++++++++ internal/daemon/socket.go | 132 +++++++++++++++++- internal/observability/crash.go | 39 +++--- internal/observability/crash_test.go | 44 ++++++ .../observability/rename_noreplace_darwin.go | 19 +++ .../observability/rename_noreplace_linux.go | 19 +++ .../observability/rename_noreplace_other.go | 12 ++ .../observability/rename_noreplace_windows.go | 84 +++++++++++ 9 files changed, 417 insertions(+), 32 deletions(-) create mode 100644 internal/observability/rename_noreplace_darwin.go create mode 100644 internal/observability/rename_noreplace_linux.go create mode 100644 internal/observability/rename_noreplace_other.go create mode 100644 internal/observability/rename_noreplace_windows.go diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 6879e9b90..e9d3d0cea 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -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) } diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 312b3bdaf..7c2762b24 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -4,12 +4,105 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" "time" "github.com/Gitlawb/zero/internal/observability" ) +func TestSecureRuntimeParentsLeaveCustomDirectoryPermissionsUntouched(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission regression") + } + dir := t.TempDir() + 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"), + } + if err := secureRuntimeParents(paths); err != nil { + t.Fatalf("secureRuntimeParents: %v", err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("custom directory permissions = %04o, want unchanged 0755", got) + } +} + +func TestSecureRuntimeParentsLeaveRelativeWorkingDirectoryPermissionsUntouched(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission regression") + } + dir := t.TempDir() + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + oldWorkingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWorkingDirectory) }) + + if err := secureRuntimeParents(Paths{Socket: "daemon.sock", Lock: "daemon.lock", Status: "daemon.status"}); err != nil { + t.Fatalf("secureRuntimeParents: %v", err) + } + info, err := os.Stat(".") + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Fatalf("working directory permissions = %04o, want unchanged 0755", got) + } +} + +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() diff --git a/internal/daemon/socket.go b/internal/daemon/socket.go index 78f0474fe..0b0efb420 100644 --- a/internal/daemon/socket.go +++ b/internal/daemon/socket.go @@ -1,7 +1,9 @@ package daemon import ( + "errors" "fmt" + "os" "path/filepath" "github.com/Gitlawb/zero/internal/privatedir" @@ -13,11 +15,22 @@ import ( // safe cross-platform ceiling. const maxUnixSocketPath = 103 -// secureRuntimeParents creates and hardens every directory that can influence -// daemon coordination. Existing directories are migrated only after ownership -// is verified through a bound handle; directories owned by another user fail -// closed. +// secureRuntimeParents creates every directory that can influence daemon +// coordination. The known default runtime root is owner-verified and hardened; +// caller-supplied layouts are created when missing but existing parents are +// never chmodded because the daemon does not own that policy boundary. func secureRuntimeParents(paths Paths) error { + root, isDefault, err := openDefaultRuntimeRoot(paths) + if err != nil { + return err + } + if isDefault { + if err := root.Close(); err != nil { + return fmt.Errorf("daemon: close runtime directory: %w", err) + } + return nil + } + parents := []struct { name string path string @@ -36,13 +49,120 @@ func secureRuntimeParents(paths Paths) error { continue } seen[absolute] = struct{}{} - if err := privatedir.Ensure(absolute); err != nil { - return fmt.Errorf("daemon: secure %s directory: %w", parent.name, err) + if err := os.MkdirAll(absolute, 0o700); err != nil { + return fmt.Errorf("daemon: create %s directory: %w", parent.name, err) } } 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 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. func checkSocketPathLength(socketPath string) error { if len(socketPath) > maxUnixSocketPath { diff --git a/internal/observability/crash.go b/internal/observability/crash.go index dd83897b9..1fe3b478a 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -44,10 +44,11 @@ func (err *crashReportCommittedError) Unwrap() []error { } type crashReportHooks struct { - beforePublish func() - write func(*os.File, []byte) (int, error) - link func(*os.Root, string, string) error - remove func(*os.Root, string) error + 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. @@ -136,13 +137,10 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti if errors.Is(linkErr, os.ErrExist) { return "", fmt.Errorf("publish crash report: %w", linkErr) } - publishName, err = fallbackCrashName(root, name, tempName) + 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) } - if err := root.Rename(tempName, publishName); err != nil { - return "", errors.Join(fmt.Errorf("publish crash report with hard link: %w", linkErr), fmt.Errorf("publish crash report with atomic rename: %w", err)) - } committed = true tempName = "" } else { @@ -162,19 +160,20 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti return path, nil } -// fallbackCrashName selects an unpredictable vacant name for filesystems that -// cannot create hard links. Rename then publishes the already-synced staging -// file atomically without replacing the timestamp-only name or any other -// crash report that was present when the fallback name was selected. The -// directory is private to the current user, which excludes cross-user races. -func fallbackCrashName(root *os.Root, timestampName, tempName string) (string, error) { +// 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 := root.Lstat(candidate); errors.Is(err, os.ErrNotExist) { + if err := rename(root, tempName, candidate); err == nil { return candidate, nil - } else if err != nil { - return "", fmt.Errorf("inspect fallback crash report path: %w", err) + } else if !errors.Is(err, os.ErrExist) { + return "", fmt.Errorf("publish fallback crash report: %w", err) } } for range 100 { @@ -183,10 +182,10 @@ func fallbackCrashName(root *os.Root, timestampName, tempName string) (string, e return "", fmt.Errorf("generate fallback crash report name: %w", err) } candidate := base + "-" + hex.EncodeToString(suffix[:]) + filepath.Ext(timestampName) - if _, err := root.Lstat(candidate); errors.Is(err, os.ErrNotExist) { + if err := rename(root, tempName, candidate); err == nil { return candidate, nil - } else if err != nil { - return "", fmt.Errorf("inspect fallback crash report path: %w", err) + } 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") diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index 2688605d3..cd5a0260b 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -341,6 +341,50 @@ func TestWriteCrashReportFallsBackWhenHardLinksAreUnavailable(t *testing.T) { } } +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 diff --git a/internal/observability/rename_noreplace_darwin.go b/internal/observability/rename_noreplace_darwin.go new file mode 100644 index 000000000..7be01852b --- /dev/null +++ b/internal/observability/rename_noreplace_darwin.go @@ -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) +} diff --git a/internal/observability/rename_noreplace_linux.go b/internal/observability/rename_noreplace_linux.go new file mode 100644 index 000000000..bab6dc4b8 --- /dev/null +++ b/internal/observability/rename_noreplace_linux.go @@ -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) +} diff --git a/internal/observability/rename_noreplace_other.go b/internal/observability/rename_noreplace_other.go new file mode 100644 index 000000000..ee43bc1b2 --- /dev/null +++ b/internal/observability/rename_noreplace_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux && !windows + +package observability + +import ( + "errors" + "os" +) + +func renameNoReplace(*os.Root, string, string) error { + return errors.ErrUnsupported +} diff --git a/internal/observability/rename_noreplace_windows.go b/internal/observability/rename_noreplace_windows.go new file mode 100644 index 000000000..7c19df596 --- /dev/null +++ b/internal/observability/rename_noreplace_windows.go @@ -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 +} From 7eb6421312d6e0b8d8c810670351dd64248bedb5 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:36:12 +0530 Subject: [PATCH 14/16] fix(daemon): bind status ownership through cleanup --- internal/daemon/server.go | 51 +++++++++++++++++-- internal/daemon/server_test.go | 90 ++++++++++++++++++++++++++++++++++ internal/daemon/status_file.go | 73 ++++++++++----------------- 3 files changed, 162 insertions(+), 52 deletions(-) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index d5fb33495..78baf0c25 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -7,6 +7,7 @@ import ( "fmt" "net" "os" + "path/filepath" "sync" "time" ) @@ -23,6 +24,12 @@ type Server struct { listener net.Listener conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return lock *fileLock + // 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 @@ -94,6 +101,12 @@ func (s *Server) Serve() error { } s.lock = lock defer s.cleanup() + 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) // 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. @@ -196,7 +209,17 @@ func (s *Server) cleanup() { _ = s.listener.Close() } _ = os.Remove(s.opts.Paths.Socket) - _ = os.Remove(s.opts.Paths.Status) + 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 err := s.statusRoot.Close(); err != nil { + s.logf("daemon: close status directory: %v", err) + } + s.statusRoot = nil + } if s.lock != nil { _ = s.lock.release() } @@ -213,10 +236,28 @@ func (s *Server) writeStatusFile() error { if err != nil { return err } - if err := writeStatusFileAtomically(s.opts.Paths.Status, data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent); err != nil { - var committed *statusFileCommittedError - if errors.As(err, &committed) { - s.logf("daemon: %v", committed) + 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) diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 7c2762b24..30a0ab859 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -237,6 +238,95 @@ 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 { diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index 345f00cb2..cdf39c83a 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -17,57 +17,37 @@ const ( statusTempPattern = statusTempPrefix + "*" ) -// statusFileCommittedError reports a warning that happened after the complete -// status document was already published. Callers must not tear down the daemon -// as though publication failed. -type statusFileCommittedError struct { - cause error -} - -func (err *statusFileCommittedError) Error() string { - return fmt.Sprintf("status file publication committed with warning: %v", err.cause) -} - -func (err *statusFileCommittedError) Unwrap() error { - return err.cause +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 } -// writeStatusFileAtomically stages a complete, synced sibling file before -// publishing it over path, so it never truncates the live document in place. -// All operations after opening the parent use one traversal-resistant Root, so -// swapping a named ancestor cannot redirect replacement or cleanup. -func writeStatusFileAtomically( - path string, +// 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, -) (returnErr error) { - dir := filepath.Dir(path) - root, err := os.OpenRoot(dir) - if err != nil { - return fmt.Errorf("open status directory: %w", err) - } - committed := false - defer func() { - if err := root.Close(); err != nil { - returnErr = errors.Join(returnErr, fmt.Errorf("close status directory: %w", err)) - } - if committed && returnErr != nil { - var committedErr *statusFileCommittedError - if !errors.As(returnErr, &committedErr) { - returnErr = &statusFileCommittedError{cause: returnErr} - } - } - }() +) (committed bool, returnErr error) { if err := validateStatusRoot(root); err != nil { - return err + return false, err } temp, tempName, err := createStatusTemp(root, perm) if err != nil { - return err + return false, err } closed := false defer func() { @@ -83,24 +63,23 @@ func writeStatusFileAtomically( }() if err := temp.Chmod(perm); err != nil { - return fmt.Errorf("set temporary status file permissions: %w", err) + return false, fmt.Errorf("set temporary status file permissions: %w", err) } if _, err := temp.Write(data); err != nil { - return fmt.Errorf("write temporary status file: %w", err) + return false, fmt.Errorf("write temporary status file: %w", err) } if err := temp.Sync(); err != nil { - return fmt.Errorf("sync temporary status file: %w", err) + return false, fmt.Errorf("sync temporary status file: %w", err) } closeErr := temp.Close() closed = true if closeErr != nil { - return fmt.Errorf("close temporary status file: %w", closeErr) + return false, fmt.Errorf("close temporary status file: %w", closeErr) } if beforeReplace != nil { beforeReplace() } - statusName := filepath.Base(path) rename := root.Rename if replace != nil { rename = func(src, dst string) error { return replace(root, src, dst) } @@ -109,7 +88,7 @@ func writeStatusFileAtomically( if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil { var committedReplacement *fsutil.CommittedReplacementCleanupError if !errors.As(err, &committedReplacement) { - return fmt.Errorf("replace status file: %w", err) + return false, fmt.Errorf("replace status file: %w", err) } committedWarning = fmt.Errorf("clean up replaced status file: %w", err) } @@ -121,9 +100,9 @@ func writeStatusFileAtomically( committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err)) } if committedWarning != nil { - return committedWarning + return committed, committedWarning } - return nil + return committed, nil } func validateStatusRoot(root *os.Root) error { From a7a4224f4b760717abfd0491fd6ca57ef18ac8ff Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:33:47 +0530 Subject: [PATCH 15/16] fix(daemon): preserve Windows status security --- internal/daemon/status_file.go | 3 + internal/daemon/status_file_windows_test.go | 63 +++++++++ internal/daemon/status_replace_other.go | 7 + internal/daemon/status_replace_windows.go | 136 ++++++++++++++++++++ internal/observability/crash.go | 3 - internal/observability/crash_test.go | 42 +++++- 6 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 internal/daemon/status_file_windows_test.go create mode 100644 internal/daemon/status_replace_other.go create mode 100644 internal/daemon/status_replace_windows.go diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index cdf39c83a..b13b4843f 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -79,6 +79,9 @@ func writeStatusFileAtomicallyRoot( 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 { diff --git a/internal/daemon/status_file_windows_test.go b/internal/daemon/status_file_windows_test.go new file mode 100644 index 000000000..8310509e8 --- /dev/null +++ b/internal/daemon/status_file_windows_test.go @@ -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 +} diff --git a/internal/daemon/status_replace_other.go b/internal/daemon/status_replace_other.go new file mode 100644 index 000000000..aa72b083e --- /dev/null +++ b/internal/daemon/status_replace_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package daemon + +import "os" + +func prepareStatusReplacement(*os.Root, string, string) error { return nil } diff --git a/internal/daemon/status_replace_windows.go b/internal/daemon/status_replace_windows.go new file mode 100644 index 000000000..c55783a9c --- /dev/null +++ b/internal/daemon/status_replace_windows.go @@ -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) +} diff --git a/internal/observability/crash.go b/internal/observability/crash.go index 1fe3b478a..1ff7a30aa 100644 --- a/internal/observability/crash.go +++ b/internal/observability/crash.go @@ -134,9 +134,6 @@ func writeCrashReport(dir, label string, recovered any, stack []byte, ts time.Ti link = func(oldname, newname string) error { return hooks.link(root, oldname, newname) } } if linkErr := link(tempName, name); linkErr != nil { - if errors.Is(linkErr, os.ErrExist) { - return "", fmt.Errorf("publish crash report: %w", linkErr) - } 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) diff --git a/internal/observability/crash_test.go b/internal/observability/crash_test.go index cd5a0260b..422f19176 100644 --- a/internal/observability/crash_test.go +++ b/internal/observability/crash_test.go @@ -30,6 +30,36 @@ 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) @@ -209,8 +239,12 @@ func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) { if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil { t.Fatal(err) } - if _, err := WriteCrashReport(dir, "cli", "boom", []byte("stack"), ts); !errors.Is(err, os.ErrExist) { - t.Fatalf("WriteCrashReport error = %v, want existing destination", 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 { @@ -223,8 +257,8 @@ func TestWriteCrashReportDoesNotOverwriteExistingReport(t *testing.T) { if err != nil { t.Fatal(err) } - if len(entries) != 1 { - t.Fatalf("crash directory contains temporary files after publish failure: %v", entries) + if len(entries) != 2 { + t.Fatalf("crash directory should contain existing and uniquely published reports: %v", entries) } } From ed2af6eee490801faedd232fab62841cd7d18810 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:45:06 +0530 Subject: [PATCH 16/16] fix(daemon): retain trusted runtime root for lifecycle --- internal/daemon/lock.go | 33 +++++++- internal/daemon/server.go | 111 +++++++++++++++++++++---- internal/daemon/server_test.go | 87 ++++++++++++++++++- internal/daemon/socket.go | 30 +++++++ internal/daemon/socket_posix.go | 4 + internal/daemon/socket_windows.go | 4 + internal/daemon/status_file.go | 4 +- internal/daemon/status_file_test.go | 31 ++++++- internal/lockutil/lockutil.go | 57 +++++++++++++ internal/lockutil/lockutil_other.go | 14 ++++ internal/lockutil/lockutil_windows.go | 4 + internal/privatedir/privatedir.go | 37 +++++++++ internal/privatedir/privatedir_test.go | 35 ++++++++ 13 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 internal/privatedir/privatedir_test.go diff --git a/internal/daemon/lock.go b/internal/daemon/lock.go index 71e3ce3e8..e3931fbe7 100644 --- a/internal/daemon/lock.go +++ b/internal/daemon/lock.go @@ -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 } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 78baf0c25..e1652f7da 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -24,6 +24,11 @@ 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 // 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. @@ -50,9 +55,11 @@ type ServerOptions struct { 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 + 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 after rooted lock acquisition } // NewServer validates options and builds a Server. @@ -92,25 +99,61 @@ func (s *Server) Serve() error { if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil { return err } - if err := secureRuntimeParents(s.opts.Paths); 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 defer s.cleanup() - 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) // 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 + } + } else { + _ = os.Remove(s.opts.Paths.Socket) + } listener, err := net.Listen("unix", s.opts.Paths.Socket) if err != nil { @@ -119,6 +162,15 @@ func (s *Server) Serve() error { s.mu.Lock() s.listener = listener s.mu.Unlock() + if s.runtimeRoot != nil { + 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). @@ -129,7 +181,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() @@ -208,21 +260,46 @@ func (s *Server) cleanup() { if s.listener != nil { _ = s.listener.Close() } - _ = 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) { + 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.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.lock != nil { - _ = s.lock.release() + 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 { diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 30a0ab859..507921279 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -16,7 +16,11 @@ func TestSecureRuntimeParentsLeaveCustomDirectoryPermissionsUntouched(t *testing if runtime.GOOS == "windows" { t.Skip("Unix permission regression") } - dir := t.TempDir() + dir, err := os.MkdirTemp("/tmp", "zero-custom-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) if err := os.Chmod(dir, 0o755); err != nil { t.Fatal(err) } @@ -66,6 +70,87 @@ func TestSecureRuntimeParentsLeaveRelativeWorkingDirectoryPermissionsUntouched(t } } +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 TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows DACL hardening has platform-specific coverage") diff --git a/internal/daemon/socket.go b/internal/daemon/socket.go index 0b0efb420..fde044be8 100644 --- a/internal/daemon/socket.go +++ b/internal/daemon/socket.go @@ -30,7 +30,10 @@ func secureRuntimeParents(paths Paths) error { } return nil } + return secureCustomRuntimeParents(paths) +} +func secureCustomRuntimeParents(paths Paths) error { parents := []struct { name string path string @@ -52,6 +55,18 @@ func secureRuntimeParents(paths Paths) error { 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 } @@ -136,6 +151,21 @@ func samePaths(left, right Paths) (bool, error) { 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 openRootAppendRegular(root *os.Root, name string) (*os.File, error) { info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { diff --git a/internal/daemon/socket_posix.go b/internal/daemon/socket_posix.go index cd49b30c3..45e90eca8 100644 --- a/internal/daemon/socket_posix.go +++ b/internal/daemon/socket_posix.go @@ -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) +} diff --git a/internal/daemon/socket_windows.go b/internal/daemon/socket_windows.go index c30d7fbeb..2d70194b2 100644 --- a/internal/daemon/socket_windows.go +++ b/internal/daemon/socket_windows.go @@ -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 } diff --git a/internal/daemon/status_file.go b/internal/daemon/status_file.go index b13b4843f..5af2f6eda 100644 --- a/internal/daemon/status_file.go +++ b/internal/daemon/status_file.go @@ -116,8 +116,8 @@ func validateStatusRoot(root *os.Root) error { if !info.IsDir() { return fmt.Errorf("status directory is not a directory") } - if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { - return fmt.Errorf("status directory permissions are %04o, want owner-only", info.Mode().Perm()) + 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 diff --git a/internal/daemon/status_file_test.go b/internal/daemon/status_file_test.go index 612d0352c..01c7a3a27 100644 --- a/internal/daemon/status_file_test.go +++ b/internal/daemon/status_file_test.go @@ -379,7 +379,7 @@ func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) { assertNoStatusTemps(t, movedDir) } -func TestWriteStatusFileRejectsBroadStatusDirectory(t *testing.T) { +func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows directory access is governed by DACLs, not Unix mode bits") } @@ -397,11 +397,34 @@ func TestWriteStatusFileRejectsBroadStatusDirectory(t *testing.T) { }, } err := server.writeStatusFile() - if err == nil || !strings.Contains(err.Error(), "want owner-only") { - t.Fatalf("writeStatusFile error = %v, want owner-only directory rejection", err) + 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 broad directory: %v", err) + t.Fatalf("status file created in writable directory: %v", err) } assertNoStatusTemps(t, dir) } diff --git a/internal/lockutil/lockutil.go b/internal/lockutil/lockutil.go index da58d5e3b..68e6e27c8 100644 --- a/internal/lockutil/lockutil.go +++ b/internal/lockutil/lockutil.go @@ -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 { diff --git a/internal/lockutil/lockutil_other.go b/internal/lockutil/lockutil_other.go index 1b3c3affc..216b7da49 100644 --- a/internal/lockutil/lockutil_other.go +++ b/internal/lockutil/lockutil_other.go @@ -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 { diff --git a/internal/lockutil/lockutil_windows.go b/internal/lockutil/lockutil_windows.go index df98aac80..8fbfc9fca 100644 --- a/internal/lockutil/lockutil_windows.go +++ b/internal/lockutil/lockutil_windows.go @@ -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, diff --git a/internal/privatedir/privatedir.go b/internal/privatedir/privatedir.go index 16114af15..37e587ca8 100644 --- a/internal/privatedir/privatedir.go +++ b/internal/privatedir/privatedir.go @@ -33,10 +33,34 @@ func Open(path string) (*os.Root, error) { 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 { @@ -44,5 +68,18 @@ func Open(path string) (*os.Root, error) { } 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 } diff --git a/internal/privatedir/privatedir_test.go b/internal/privatedir/privatedir_test.go new file mode 100644 index 000000000..6cfe16dbe --- /dev/null +++ b/internal/privatedir/privatedir_test.go @@ -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) + } +}