detached 7888811

fix(security): verify private runtime directories

commit
7888811931480620386938d27b238d5494cb5e34
author
brice · · 2026-07-08 13:55 UTC
committer
brice · · 2026-07-08 20:59 UTC
parent
7e909e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +405 -47
M internal/adapters/dgram/proxy.go +3 -1
@@ -58,6 +58,8 @@ func (p ProxyRuntime) Run(ctx context.Context) error {
5858 _ = p.Client.Close()
5959 _ = p.Daemon.Close()
6060 }()
61+ copyCtx, cancelCopies := context.WithCancel(ctx)
62+ defer cancelCopies()
6163
6264 idleTTL := p.IdleTTL
6365 if idleTTL <= 0 {
@@ -73,7 +75,7 @@ func (p ProxyRuntime) Run(ctx context.Context) error {
7375 }
7476
7577 errCh := make(chan proxyCopyErr, 2)
76- copier := proxyCopier{ctx: ctx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
78+ copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
7779 clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend}
7880 daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
7981 go copier.copyFrames(clientToDaemon)
@@ -58,6 +58,8 @@ func (p ProxyRuntime) Run(ctx context.Context) error {
58 _ = p.Client.Close()58 _ = p.Client.Close()
59 _ = p.Daemon.Close()59 _ = p.Daemon.Close()
60 }()60 }()
61+ copyCtx, cancelCopies := context.WithCancel(ctx)
62+ defer cancelCopies()
61 63
62 idleTTL := p.IdleTTL64 idleTTL := p.IdleTTL
63 if idleTTL <= 0 {65 if idleTTL <= 0 {
@@ -73,7 +75,7 @@ func (p ProxyRuntime) Run(ctx context.Context) error {
73 }75 }
74 76
75 errCh := make(chan proxyCopyErr, 2)77 errCh := make(chan proxyCopyErr, 2)
76- copier := proxyCopier{ctx: ctx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}78+ copier := proxyCopier{ctx: copyCtx, errCh: errCh, retryBackoff: retryBackoff, clk: clk}
77 clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend}79 clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend}
78 daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}80 daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true}
79 go copier.copyFrames(clientToDaemon)81 go copier.copyFrames(clientToDaemon)
M internal/adapters/dgram/proxy_registry.go +3 -1
@@ -9,6 +9,8 @@ import (
99 "path/filepath"
1010 "syscall"
1111 "time"
12+
13+ "github.com/bnema/vev/pkg/safedir"
1214 )
1315
1416 type ProxyRecord struct {
@@ -94,7 +96,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
9496 }
9597
9698 func (r *ProxyRegistry) withLock(fn func() error) error {
97- if err := os.MkdirAll(r.dir, 0o700); err != nil {
99+ if err := safedir.EnsurePrivate(r.dir); err != nil {
98100 return err
99101 }
100102 lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
@@ -9,6 +9,8 @@ import (
9 "path/filepath"9 "path/filepath"
10 "syscall"10 "syscall"
11 "time"11 "time"
12+
13+ "github.com/bnema/vev/pkg/safedir"
12 )14 )
13 15
14 type ProxyRecord struct {16 type ProxyRecord struct {
@@ -94,7 +96,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
94 }96 }
95 97
96 func (r *ProxyRegistry) withLock(fn func() error) error {98 func (r *ProxyRegistry) withLock(fn func() error) error {
97- if err := os.MkdirAll(r.dir, 0o700); err != nil {99+ if err := safedir.EnsurePrivate(r.dir); err != nil {
98 return err100 return err
99 }101 }
100 lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)102 lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
M internal/adapters/dgram/proxy_registry_test.go +10 -4
@@ -2,12 +2,18 @@ package dgram
22
33 import (
44 "os"
5+ "path/filepath"
56 "testing"
67 )
78
9+func privateDir(t *testing.T) string {
10+ t.Helper()
11+ return filepath.Join(t.TempDir(), "vev")
12+}
13+
814 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
915 t.Run("reuses live matching record", func(t *testing.T) {
10- r := NewProxyRegistry(t.TempDir())
16+ r := NewProxyRegistry(privateDir(t))
1117 want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
1218 if err := r.Publish(want); err != nil {
1319 t.Fatal(err)
@@ -21,7 +27,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
2127 }
2228 })
2329 t.Run("removes stale record", func(t *testing.T) {
24- r := NewProxyRegistry(t.TempDir())
30+ r := NewProxyRegistry(privateDir(t))
2531 if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
2632 t.Fatal(err)
2733 }
@@ -36,7 +42,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
3642 }
3743 })
3844 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
39- r := NewProxyRegistry(t.TempDir())
45+ r := NewProxyRegistry(privateDir(t))
4046 old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
4147 if err := r.Publish(old); err != nil {
4248 t.Fatal(err)
@@ -65,7 +71,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
6571 }
6672
6773 func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
68- r := NewProxyRegistry(t.TempDir())
74+ r := NewProxyRegistry(privateDir(t))
6975 tests := []struct {
7076 a string
7177 b string
@@ -2,12 +2,18 @@ package dgram
2 2
3 import (3 import (
4 "os"4 "os"
5+ "path/filepath"
5 "testing"6 "testing"
6 )7 )
7 8
9+func privateDir(t *testing.T) string {
10+ t.Helper()
11+ return filepath.Join(t.TempDir(), "vev")
12+}
13+
8 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {14 func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
9 t.Run("reuses live matching record", func(t *testing.T) {15 t.Run("reuses live matching record", func(t *testing.T) {
10- r := NewProxyRegistry(t.TempDir())16+ r := NewProxyRegistry(privateDir(t))
11 want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}17 want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}
12 if err := r.Publish(want); err != nil {18 if err := r.Publish(want); err != nil {
13 t.Fatal(err)19 t.Fatal(err)
@@ -21,7 +27,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
21 }27 }
22 })28 })
23 t.Run("removes stale record", func(t *testing.T) {29 t.Run("removes stale record", func(t *testing.T) {
24- r := NewProxyRegistry(t.TempDir())30+ r := NewProxyRegistry(privateDir(t))
25 if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {31 if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil {
26 t.Fatal(err)32 t.Fatal(err)
27 }33 }
@@ -36,7 +42,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
36 }42 }
37 })43 })
38 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {44 t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
39- r := NewProxyRegistry(t.TempDir())45+ r := NewProxyRegistry(privateDir(t))
40 old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}46 old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"}
41 if err := r.Publish(old); err != nil {47 if err := r.Publish(old); err != nil {
42 t.Fatal(err)48 t.Fatal(err)
@@ -65,7 +71,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
65 }71 }
66 72
67 func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {73 func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
68- r := NewProxyRegistry(t.TempDir())74+ r := NewProxyRegistry(privateDir(t))
69 tests := []struct {75 tests := []struct {
70 a string76 a string
71 b string77 b string
M internal/adapters/dgram/proxy_test.go +57 -0
@@ -1,6 +1,7 @@
11 package dgram
22
33 import (
4+ "context"
45 "errors"
56 "io"
67 "sync"
@@ -215,3 +216,59 @@ func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
215216 t.Fatal("timeout waiting for daemon EOF")
216217 }
217218 }
219+
220+type retryingOfflineTransport struct {
221+ closed chan struct{}
222+ firstSend chan struct{}
223+ closeOnce sync.Once
224+ firstSendOnce sync.Once
225+}
226+
227+func newRetryingOfflineTransport() *retryingOfflineTransport {
228+ return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
229+}
230+
231+func (t *retryingOfflineTransport) Send(ports.Frame) error {
232+ t.firstSendOnce.Do(func() { close(t.firstSend) })
233+ return errors.New("offline")
234+}
235+
236+func (t *retryingOfflineTransport) Recv() (ports.Frame, error) {
237+ <-t.closed
238+ return ports.Frame{}, io.EOF
239+}
240+
241+func (t *retryingOfflineTransport) Close() error {
242+ t.closeOnce.Do(func() { close(t.closed) })
243+ return nil
244+}
245+
246+func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
247+func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
248+ return nil
249+}
250+
251+func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
252+ ctx, cancel := context.WithCancel(t.Context())
253+ client := newRetryingOfflineTransport()
254+ daemon := newFakeTransport()
255+ done := make(chan struct{})
256+ copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
257+ go func() {
258+ copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
259+ close(done)
260+ }()
261+
262+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("retry until cancel")}}
263+ select {
264+ case <-client.firstSend:
265+ case <-time.After(time.Second):
266+ t.Fatal("timeout waiting for proxy send attempt")
267+ }
268+ cancel()
269+ select {
270+ case <-done:
271+ case <-time.After(time.Second):
272+ t.Fatal("retrying copier did not stop after context cancellation")
273+ }
274+}
@@ -1,6 +1,7 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "io"6 "io"
6 "sync"7 "sync"
@@ -215,3 +216,59 @@ func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) {
215 t.Fatal("timeout waiting for daemon EOF")216 t.Fatal("timeout waiting for daemon EOF")
216 }217 }
217 }218 }
219+
220+type retryingOfflineTransport struct {
221+ closed chan struct{}
222+ firstSend chan struct{}
223+ closeOnce sync.Once
224+ firstSendOnce sync.Once
225+}
226+
227+func newRetryingOfflineTransport() *retryingOfflineTransport {
228+ return &retryingOfflineTransport{closed: make(chan struct{}), firstSend: make(chan struct{})}
229+}
230+
231+func (t *retryingOfflineTransport) Send(ports.Frame) error {
232+ t.firstSendOnce.Do(func() { close(t.firstSend) })
233+ return errors.New("offline")
234+}
235+
236+func (t *retryingOfflineTransport) Recv() (ports.Frame, error) {
237+ <-t.closed
238+ return ports.Frame{}, io.EOF
239+}
240+
241+func (t *retryingOfflineTransport) Close() error {
242+ t.closeOnce.Do(func() { close(t.closed) })
243+ return nil
244+}
245+
246+func (t *retryingOfflineTransport) LinkState() ports.LinkState { return ports.LinkStateOffline }
247+func (t *retryingOfflineTransport) LinkEvents() <-chan ports.LinkEvent {
248+ return nil
249+}
250+
251+func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
252+ ctx, cancel := context.WithCancel(t.Context())
253+ client := newRetryingOfflineTransport()
254+ daemon := newFakeTransport()
255+ done := make(chan struct{})
256+ copier := proxyCopier{ctx: ctx, errCh: make(chan<- proxyCopyErr), retryBackoff: time.Hour, clk: realClock{}}
257+ go func() {
258+ copier.copyFrames(proxyCopyDirection{src: daemon, dst: client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true})
259+ close(done)
260+ }()
261+
262+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("retry until cancel")}}
263+ select {
264+ case <-client.firstSend:
265+ case <-time.After(time.Second):
266+ t.Fatal("timeout waiting for proxy send attempt")
267+ }
268+ cancel()
269+ select {
270+ case <-done:
271+ case <-time.After(time.Second):
272+ t.Fatal("retrying copier did not stop after context cancellation")
273+ }
274+}
M internal/adapters/dgram/transport_test.go +14 -5
@@ -238,16 +238,25 @@ func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
238238 type deadlineCapturePC struct {
239239 addr net.Addr
240240 deadline atomic.Value
241+ done chan struct{}
241242 }
242243
243244 func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
245+ <-p.done
244246 return 0, nil, errors.New("closed")
245247 }
246248 func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
247-func (p *deadlineCapturePC) Close() error { return nil }
248-func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
249-func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
250-func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
249+func (p *deadlineCapturePC) Close() error {
250+ select {
251+ case <-p.done:
252+ default:
253+ close(p.done)
254+ }
255+ return nil
256+}
257+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
258+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
259+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
251260 func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
252261 if !t.IsZero() {
253262 p.deadline.Store(t)
@@ -773,7 +782,7 @@ func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
773782
774783 func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
775784 wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
776- pc := &deadlineCapturePC{addr: testAddr("a")}
785+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
777786 tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
778787 Clock: fixedClock{now: wantNow},
779788 WriteTimeout: 250 * time.Millisecond,
@@ -238,16 +238,25 @@ func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
238 type deadlineCapturePC struct {238 type deadlineCapturePC struct {
239 addr net.Addr239 addr net.Addr
240 deadline atomic.Value240 deadline atomic.Value
241+ done chan struct{}
241 }242 }
242 243
243 func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {244 func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
245+ <-p.done
244 return 0, nil, errors.New("closed")246 return 0, nil, errors.New("closed")
245 }247 }
246 func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }248 func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
247-func (p *deadlineCapturePC) Close() error { return nil }249+func (p *deadlineCapturePC) Close() error {
248-func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }250+ select {
249-func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }251+ case <-p.done:
250-func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }252+ default:
253+ close(p.done)
254+ }
255+ return nil
256+}
257+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
258+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
259+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
251 func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {260 func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
252 if !t.IsZero() {261 if !t.IsZero() {
253 p.deadline.Store(t)262 p.deadline.Store(t)
@@ -773,7 +782,7 @@ func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
773 782
774 func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {783 func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
775 wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)784 wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
776- pc := &deadlineCapturePC{addr: testAddr("a")}785+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
777 tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{786 tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
778 Clock: fixedClock{now: wantNow},787 Clock: fixedClock{now: wantNow},
779 WriteTimeout: 250 * time.Millisecond,788 WriteTimeout: 250 * time.Millisecond,
M internal/adapters/ipc/listener.go +3 -2
@@ -9,6 +9,7 @@ import (
99 "syscall"
1010
1111 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/safedir"
1213 )
1314
1415 // socketFileName is the fixed name of the daemon's listening socket within
@@ -35,8 +36,8 @@ type unixListener struct {
3536 // unlinked and bind is retried once. A successful dial means a live daemon
3637 // owns the socket, and Listen returns ErrDaemonRunning.
3738 func Listen(dir string) (ports.Listener, error) {
38- if err := os.MkdirAll(dir, 0o700); err != nil {
39- return nil, fmt.Errorf("ipc: creating socket directory: %w", err)
39+ if err := safedir.EnsurePrivate(dir); err != nil {
40+ return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
4041 }
4142
4243 sockPath := filepath.Join(dir, socketFileName)
@@ -9,6 +9,7 @@ import (
9 "syscall"9 "syscall"
10 10
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/safedir"
12 )13 )
13 14
14 // socketFileName is the fixed name of the daemon's listening socket within15 // socketFileName is the fixed name of the daemon's listening socket within
@@ -35,8 +36,8 @@ type unixListener struct {
35 // unlinked and bind is retried once. A successful dial means a live daemon36 // unlinked and bind is retried once. A successful dial means a live daemon
36 // owns the socket, and Listen returns ErrDaemonRunning.37 // owns the socket, and Listen returns ErrDaemonRunning.
37 func Listen(dir string) (ports.Listener, error) {38 func Listen(dir string) (ports.Listener, error) {
38- if err := os.MkdirAll(dir, 0o700); err != nil {39+ if err := safedir.EnsurePrivate(dir); err != nil {
39- return nil, fmt.Errorf("ipc: creating socket directory: %w", err)40+ return nil, fmt.Errorf("ipc: securing socket directory: %w", err)
40 }41 }
41 42
42 sockPath := filepath.Join(dir, socketFileName)43 sockPath := filepath.Join(dir, socketFileName)
M internal/adapters/ipc/listener_test.go +26 -3
@@ -6,13 +6,14 @@ import (
66 "os"
77 "path/filepath"
88 "strconv"
9+ "strings"
910 "testing"
1011
1112 "github.com/bnema/vev/internal/ports"
1213 )
1314
1415 func TestListenAcceptRoundTrip(t *testing.T) {
15- dir := t.TempDir()
16+ dir := filepath.Join(t.TempDir(), "vev")
1617
1718 ln, err := Listen(dir)
1819 if err != nil {
@@ -85,8 +86,30 @@ func TestListenMkdirsSocketDir(t *testing.T) {
8586 }
8687 }
8788
89+func TestListenRejectsHostileSocketDir(t *testing.T) {
90+ dir := filepath.Join(t.TempDir(), "socketdir")
91+ if err := os.MkdirAll(dir, 0o777); err != nil {
92+ t.Fatalf("mkdir hostile socket dir: %v", err)
93+ }
94+ if err := os.Chmod(dir, 0o777); err != nil {
95+ t.Fatalf("chmod hostile socket dir: %v", err)
96+ }
97+
98+ ln, err := Listen(dir)
99+ if err == nil {
100+ _ = ln.Close()
101+ t.Fatal("Listen() error = nil, want hostile directory rejection")
102+ }
103+ if !strings.Contains(err.Error(), dir) {
104+ t.Fatalf("Listen() error = %q, want mention of %q", err, dir)
105+ }
106+}
107+
88108 func TestListenStaleSocketCleanedUp(t *testing.T) {
89- dir := t.TempDir()
109+ dir := filepath.Join(t.TempDir(), "vev")
110+ if err := os.Mkdir(dir, 0o700); err != nil {
111+ t.Fatalf("mkdir safe socket dir: %v", err)
112+ }
90113 sockPath := filepath.Join(dir, "daemon.sock")
91114
92115 // Create a socket file with nobody listening behind it: bind, then
@@ -117,7 +140,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
117140 }
118141
119142 func TestListenLiveDaemonRejected(t *testing.T) {
120- dir := t.TempDir()
143+ dir := filepath.Join(t.TempDir(), "vev")
121144
122145 first, err := Listen(dir)
123146 if err != nil {
@@ -6,13 +6,14 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strconv"8 "strconv"
9+ "strings"
9 "testing"10 "testing"
10 11
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 )13 )
13 14
14 func TestListenAcceptRoundTrip(t *testing.T) {15 func TestListenAcceptRoundTrip(t *testing.T) {
15- dir := t.TempDir()16+ dir := filepath.Join(t.TempDir(), "vev")
16 17
17 ln, err := Listen(dir)18 ln, err := Listen(dir)
18 if err != nil {19 if err != nil {
@@ -85,8 +86,30 @@ func TestListenMkdirsSocketDir(t *testing.T) {
85 }86 }
86 }87 }
87 88
89+func TestListenRejectsHostileSocketDir(t *testing.T) {
90+ dir := filepath.Join(t.TempDir(), "socketdir")
91+ if err := os.MkdirAll(dir, 0o777); err != nil {
92+ t.Fatalf("mkdir hostile socket dir: %v", err)
93+ }
94+ if err := os.Chmod(dir, 0o777); err != nil {
95+ t.Fatalf("chmod hostile socket dir: %v", err)
96+ }
97+
98+ ln, err := Listen(dir)
99+ if err == nil {
100+ _ = ln.Close()
101+ t.Fatal("Listen() error = nil, want hostile directory rejection")
102+ }
103+ if !strings.Contains(err.Error(), dir) {
104+ t.Fatalf("Listen() error = %q, want mention of %q", err, dir)
105+ }
106+}
107+
88 func TestListenStaleSocketCleanedUp(t *testing.T) {108 func TestListenStaleSocketCleanedUp(t *testing.T) {
89- dir := t.TempDir()109+ dir := filepath.Join(t.TempDir(), "vev")
110+ if err := os.Mkdir(dir, 0o700); err != nil {
111+ t.Fatalf("mkdir safe socket dir: %v", err)
112+ }
90 sockPath := filepath.Join(dir, "daemon.sock")113 sockPath := filepath.Join(dir, "daemon.sock")
91 114
92 // Create a socket file with nobody listening behind it: bind, then115 // Create a socket file with nobody listening behind it: bind, then
@@ -117,7 +140,7 @@ func TestListenStaleSocketCleanedUp(t *testing.T) {
117 }140 }
118 141
119 func TestListenLiveDaemonRejected(t *testing.T) {142 func TestListenLiveDaemonRejected(t *testing.T) {
120- dir := t.TempDir()143+ dir := filepath.Join(t.TempDir(), "vev")
121 144
122 first, err := Listen(dir)145 first, err := Listen(dir)
123 if err != nil {146 if err != nil {
M internal/adapters/snapshot/store.go +2 -4
@@ -13,6 +13,7 @@ import (
1313 "strings"
1414
1515 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/safedir"
1617 )
1718
1819 var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
@@ -35,12 +36,9 @@ func NewStore(dir string) *Store {
3536 // Write atomically writes data for name. The previous snapshot remains in place
3637 // until the final rename succeeds.
3738 func (s *Store) Write(name string, data []byte) error {
38- if err := os.MkdirAll(s.dir, 0o700); err != nil {
39+ if err := safedir.EnsurePrivate(s.dir); err != nil {
3940 return fmt.Errorf("create snapshot dir: %w", err)
4041 }
41- if err := os.Chmod(s.dir, 0o700); err != nil {
42- return fmt.Errorf("chmod snapshot dir: %w", err)
43- }
4442
4543 tmp, err := os.CreateTemp(s.dir, ".tmp-*")
4644 if err != nil {
@@ -13,6 +13,7 @@ import (
13 "strings"13 "strings"
14 14
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/safedir"
16 )17 )
17 18
18 var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)19 var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
@@ -35,12 +36,9 @@ func NewStore(dir string) *Store {
35 // Write atomically writes data for name. The previous snapshot remains in place36 // Write atomically writes data for name. The previous snapshot remains in place
36 // until the final rename succeeds.37 // until the final rename succeeds.
37 func (s *Store) Write(name string, data []byte) error {38 func (s *Store) Write(name string, data []byte) error {
38- if err := os.MkdirAll(s.dir, 0o700); err != nil {39+ if err := safedir.EnsurePrivate(s.dir); err != nil {
39 return fmt.Errorf("create snapshot dir: %w", err)40 return fmt.Errorf("create snapshot dir: %w", err)
40 }41 }
41- if err := os.Chmod(s.dir, 0o700); err != nil {
42- return fmt.Errorf("chmod snapshot dir: %w", err)
43- }
44 42
45 tmp, err := os.CreateTemp(s.dir, ".tmp-*")43 tmp, err := os.CreateTemp(s.dir, ".tmp-*")
46 if err != nil {44 if err != nil {
M internal/adapters/snapshot/store_test.go +16 -5
@@ -12,9 +12,14 @@ import (
1212 "testing"
1313 )
1414
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
1520 func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
1621 t.Parallel()
17- dir := t.TempDir()
22+ dir := privateDir(t)
1823 store := NewStore(dir)
1924
2025 if err := store.Write("alpha", []byte("first")); err != nil {
@@ -44,7 +49,7 @@ func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
4449
4550 func TestStoreDelete(t *testing.T) {
4651 t.Parallel()
47- dir := t.TempDir()
52+ dir := privateDir(t)
4853 store := NewStore(dir)
4954
5055 if err := store.Write("gone", []byte("data")); err != nil {
@@ -63,7 +68,7 @@ func TestStoreDelete(t *testing.T) {
6368
6469 func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
6570 t.Parallel()
66- dir := t.TempDir()
71+ dir := privateDir(t)
6772 store := NewStore(dir)
6873 name := "../unsafe/session name"
6974
@@ -120,8 +125,11 @@ func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
120125
121126 func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
122127 t.Parallel()
123- dir := t.TempDir()
128+ dir := privateDir(t)
124129 store := NewStore(dir)
130+ if err := os.MkdirAll(dir, 0o700); err != nil {
131+ t.Fatalf("MkdirAll: %v", err)
132+ }
125133 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
126134 t.Fatalf("write ok: %v", err)
127135 }
@@ -149,8 +157,11 @@ func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
149157 t.Skip("permission bits do not make file unreadable on this platform/user")
150158 }
151159 t.Parallel()
152- dir := t.TempDir()
160+ dir := privateDir(t)
153161 store := NewStore(dir)
162+ if err := os.MkdirAll(dir, 0o700); err != nil {
163+ t.Fatalf("MkdirAll: %v", err)
164+ }
154165 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
155166 t.Fatalf("write ok: %v", err)
156167 }
@@ -12,9 +12,14 @@ import (
12 "testing"12 "testing"
13 )13 )
14 14
15+func privateDir(t *testing.T) string {
16+ t.Helper()
17+ return filepath.Join(t.TempDir(), "vev")
18+}
19+
15 func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {20 func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
16 t.Parallel()21 t.Parallel()
17- dir := t.TempDir()22+ dir := privateDir(t)
18 store := NewStore(dir)23 store := NewStore(dir)
19 24
20 if err := store.Write("alpha", []byte("first")); err != nil {25 if err := store.Write("alpha", []byte("first")); err != nil {
@@ -44,7 +49,7 @@ func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) {
44 49
45 func TestStoreDelete(t *testing.T) {50 func TestStoreDelete(t *testing.T) {
46 t.Parallel()51 t.Parallel()
47- dir := t.TempDir()52+ dir := privateDir(t)
48 store := NewStore(dir)53 store := NewStore(dir)
49 54
50 if err := store.Write("gone", []byte("data")); err != nil {55 if err := store.Write("gone", []byte("data")); err != nil {
@@ -63,7 +68,7 @@ func TestStoreDelete(t *testing.T) {
63 68
64 func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {69 func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) {
65 t.Parallel()70 t.Parallel()
66- dir := t.TempDir()71+ dir := privateDir(t)
67 store := NewStore(dir)72 store := NewStore(dir)
68 name := "../unsafe/session name"73 name := "../unsafe/session name"
69 74
@@ -120,8 +125,11 @@ func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) {
120 125
121 func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {126 func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) {
122 t.Parallel()127 t.Parallel()
123- dir := t.TempDir()128+ dir := privateDir(t)
124 store := NewStore(dir)129 store := NewStore(dir)
130+ if err := os.MkdirAll(dir, 0o700); err != nil {
131+ t.Fatalf("MkdirAll: %v", err)
132+ }
125 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {133 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
126 t.Fatalf("write ok: %v", err)134 t.Fatalf("write ok: %v", err)
127 }135 }
@@ -149,8 +157,11 @@ func TestStoreLoadSkipsUnreadableFile(t *testing.T) {
149 t.Skip("permission bits do not make file unreadable on this platform/user")157 t.Skip("permission bits do not make file unreadable on this platform/user")
150 }158 }
151 t.Parallel()159 t.Parallel()
152- dir := t.TempDir()160+ dir := privateDir(t)
153 store := NewStore(dir)161 store := NewStore(dir)
162+ if err := os.MkdirAll(dir, 0o700); err != nil {
163+ t.Fatalf("MkdirAll: %v", err)
164+ }
154 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {165 if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil {
155 t.Fatalf("write ok: %v", err)166 t.Fatalf("write ok: %v", err)
156 }167 }
M internal/app/daemon_integration_test.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "io"
99 "log/slog"
10+ "path/filepath"
1011 "strings"
1112 "testing"
1213 "time"
@@ -35,7 +36,7 @@ func discardLog() *slog.Logger {
3536 // The returned served channel receives Serve's result exactly once.
3637 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
3738 t.Helper()
38- return startDaemonInDir(t, t.TempDir(), opts...)
39+ return startDaemonInDir(t, filepath.Join(t.TempDir(), "vev"), opts...)
3940 }
4041
4142 func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "io"8 "io"
9 "log/slog"9 "log/slog"
10+ "path/filepath"
10 "strings"11 "strings"
11 "testing"12 "testing"
12 "time"13 "time"
@@ -35,7 +36,7 @@ func discardLog() *slog.Logger {
35 // The returned served channel receives Serve's result exactly once.36 // The returned served channel receives Serve's result exactly once.
36 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {37 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
37 t.Helper()38 t.Helper()
38- return startDaemonInDir(t, t.TempDir(), opts...)39+ return startDaemonInDir(t, filepath.Join(t.TempDir(), "vev"), opts...)
39 }40 }
40 41
41 func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {42 func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
M internal/app/spawn.go +2 -1
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/adapters/ipc"
1515 "github.com/bnema/vev/internal/platform"
1616 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/pkg/safedir"
1718 )
1819
1920 // ErrDaemonUnreachable is returned when the daemon socket never becomes
@@ -88,7 +89,7 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
8889 // no-op release) when another process holds a fresh lock, and takes over a
8990 // lock older than staleLockAge (crash resilience).
9091 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
91- if err := os.MkdirAll(dir, 0o700); err != nil {
92+ if err := safedir.EnsurePrivate(dir); err != nil {
9293 return nil, false, err
9394 }
9495 path := filepath.Join(dir, spawnLockName)
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/adapters/ipc"14 "github.com/bnema/vev/internal/adapters/ipc"
15 "github.com/bnema/vev/internal/platform"15 "github.com/bnema/vev/internal/platform"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/pkg/safedir"
17 )18 )
18 19
19 // ErrDaemonUnreachable is returned when the daemon socket never becomes20 // ErrDaemonUnreachable is returned when the daemon socket never becomes
@@ -88,7 +89,7 @@ func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFun
88 // no-op release) when another process holds a fresh lock, and takes over a89 // no-op release) when another process holds a fresh lock, and takes over a
89 // lock older than staleLockAge (crash resilience).90 // lock older than staleLockAge (crash resilience).
90 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {91 func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
91- if err := os.MkdirAll(dir, 0o700); err != nil {92+ if err := safedir.EnsurePrivate(dir); err != nil {
92 return nil, false, err93 return nil, false, err
93 }94 }
94 path := filepath.Join(dir, spawnLockName)95 path := filepath.Join(dir, spawnLockName)
M internal/app/spawn_test.go +7 -4
@@ -20,7 +20,7 @@ var errDialFailed = errors.New("dial failed")
2020 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
2121
2222 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
23- dir := t.TempDir()
23+ dir := filepath.Join(t.TempDir(), "vev")
2424 const racers = 16
2525
2626 var wins atomic.Int32
@@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
6262 }
6363
6464 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
65- dir := t.TempDir()
65+ dir := filepath.Join(t.TempDir(), "vev")
66+ if err := os.Mkdir(dir, 0o700); err != nil {
67+ t.Fatalf("seeding socket dir: %v", err)
68+ }
6669 lockPath := filepath.Join(dir, spawnLockName)
6770 if err := os.Mkdir(lockPath, 0o700); err != nil {
6871 t.Fatalf("seeding lock: %v", err)
@@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
126129 }
127130
128131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
129- dir := t.TempDir()
132+ dir := filepath.Join(t.TempDir(), "vev")
130133 want := portsmocks.NewMockTransport(t)
131134 var dialCalls, spawnCalls atomic.Int32
132135 dial := func(string) (ports.Transport, error) {
@@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) {
180183 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
181184 spawn := func() error { return spawnErr }
182185
183- _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
186+ _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
184187 if !errors.Is(err, spawnErr) {
185188 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)
186189 }
@@ -20,7 +20,7 @@ var errDialFailed = errors.New("dial failed")
20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}20 var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond}
21 21
22 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {22 func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
23- dir := t.TempDir()23+ dir := filepath.Join(t.TempDir(), "vev")
24 const racers = 1624 const racers = 16
25 25
26 var wins atomic.Int3226 var wins atomic.Int32
@@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) {
62 }62 }
63 63
64 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {64 func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
65- dir := t.TempDir()65+ dir := filepath.Join(t.TempDir(), "vev")
66+ if err := os.Mkdir(dir, 0o700); err != nil {
67+ t.Fatalf("seeding socket dir: %v", err)
68+ }
66 lockPath := filepath.Join(dir, spawnLockName)69 lockPath := filepath.Join(dir, spawnLockName)
67 if err := os.Mkdir(lockPath, 0o700); err != nil {70 if err := os.Mkdir(lockPath, 0o700); err != nil {
68 t.Fatalf("seeding lock: %v", err)71 t.Fatalf("seeding lock: %v", err)
@@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) {
126 }129 }
127 130
128 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
129- dir := t.TempDir()132+ dir := filepath.Join(t.TempDir(), "vev")
130 want := portsmocks.NewMockTransport(t)133 want := portsmocks.NewMockTransport(t)
131 var dialCalls, spawnCalls atomic.Int32134 var dialCalls, spawnCalls atomic.Int32
132 dial := func(string) (ports.Transport, error) {135 dial := func(string) (ports.Transport, error) {
@@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) {
180 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }183 dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
181 spawn := func() error { return spawnErr }184 spawn := func() error { return spawnErr }
182 185
183- _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)186+ _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
184 if !errors.Is(err, spawnErr) {187 if !errors.Is(err, spawnErr) {
185 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)188 t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr)
186 }189 }
M internal/logging/logging.go +3 -1
@@ -8,6 +8,8 @@ import (
88 "runtime/debug"
99 "strings"
1010 "sync"
11+
12+ "github.com/bnema/vev/pkg/safedir"
1113 )
1214
1315 type Component string
@@ -29,7 +31,7 @@ type Config struct {
2931 }
3032
3133 func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
32- if err := os.MkdirAll(cfg.Dir, 0o700); err != nil {
34+ if err := safedir.EnsurePrivate(cfg.Dir); err != nil {
3335 return nil, nil, err
3436 }
3537
@@ -8,6 +8,8 @@ import (
8 "runtime/debug"8 "runtime/debug"
9 "strings"9 "strings"
10 "sync"10 "sync"
11+
12+ "github.com/bnema/vev/pkg/safedir"
11 )13 )
12 14
13 type Component string15 type Component string
@@ -29,7 +31,7 @@ type Config struct {
29 }31 }
30 32
31 func Setup(cfg Config) (*slog.Logger, io.Closer, error) {33 func Setup(cfg Config) (*slog.Logger, io.Closer, error) {
32- if err := os.MkdirAll(cfg.Dir, 0o700); err != nil {34+ if err := safedir.EnsurePrivate(cfg.Dir); err != nil {
33 return nil, nil, err35 return nil, nil, err
34 }36 }
35 37
M internal/logging/logging_test.go +8 -3
@@ -11,6 +11,11 @@ import (
1111 "testing"
1212 )
1313
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
1419 func TestParseLevelAndEnvLevel(t *testing.T) {
1520 tests := []struct {
1621 in string
@@ -38,7 +43,7 @@ func TestParseLevelAndEnvLevel(t *testing.T) {
3843 }
3944
4045 func TestSetupWritesJSONWithComponent(t *testing.T) {
41- dir := t.TempDir()
46+ dir := privateDir(t)
4247 logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
4348 if err != nil {
4449 t.Fatalf("Setup() error = %v", err)
@@ -143,7 +148,7 @@ func TestStartupOnlyRotation(t *testing.T) {
143148 }
144149
145150 func TestCrashOutputCapturesPanicStack(t *testing.T) {
146- dir := t.TempDir()
151+ dir := privateDir(t)
147152 cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
148153 cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
149154 if err := cmd.Run(); err == nil {
@@ -211,7 +216,7 @@ func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
211216 }
212217
213218 func TestDoubleClose(t *testing.T) {
214- _, closer, err := Setup(Config{Dir: t.TempDir(), Component: Stdio, Level: slog.LevelInfo})
219+ _, closer, err := Setup(Config{Dir: privateDir(t), Component: Stdio, Level: slog.LevelInfo})
215220 if err != nil {
216221 t.Fatalf("Setup() error = %v", err)
217222 }
@@ -11,6 +11,11 @@ import (
11 "testing"11 "testing"
12 )12 )
13 13
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
14 func TestParseLevelAndEnvLevel(t *testing.T) {19 func TestParseLevelAndEnvLevel(t *testing.T) {
15 tests := []struct {20 tests := []struct {
16 in string21 in string
@@ -38,7 +43,7 @@ func TestParseLevelAndEnvLevel(t *testing.T) {
38 }43 }
39 44
40 func TestSetupWritesJSONWithComponent(t *testing.T) {45 func TestSetupWritesJSONWithComponent(t *testing.T) {
41- dir := t.TempDir()46+ dir := privateDir(t)
42 logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})47 logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug})
43 if err != nil {48 if err != nil {
44 t.Fatalf("Setup() error = %v", err)49 t.Fatalf("Setup() error = %v", err)
@@ -143,7 +148,7 @@ func TestStartupOnlyRotation(t *testing.T) {
143 }148 }
144 149
145 func TestCrashOutputCapturesPanicStack(t *testing.T) {150 func TestCrashOutputCapturesPanicStack(t *testing.T) {
146- dir := t.TempDir()151+ dir := privateDir(t)
147 cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")152 cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper")
148 cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)153 cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir)
149 if err := cmd.Run(); err == nil {154 if err := cmd.Run(); err == nil {
@@ -211,7 +216,7 @@ func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) {
211 }216 }
212 217
213 func TestDoubleClose(t *testing.T) {218 func TestDoubleClose(t *testing.T) {
214- _, closer, err := Setup(Config{Dir: t.TempDir(), Component: Stdio, Level: slog.LevelInfo})219+ _, closer, err := Setup(Config{Dir: privateDir(t), Component: Stdio, Level: slog.LevelInfo})
215 if err != nil {220 if err != nil {
216 t.Fatalf("Setup() error = %v", err)221 t.Fatalf("Setup() error = %v", err)
217 }222 }
M internal/persist/persist_test.go +7 -2
@@ -11,6 +11,11 @@ import (
1111 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1212 )
1313
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
1419 func TestRecordCodecRoundTrip(t *testing.T) {
1520 r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
1621 value, err := encodeRecordValue(r)
@@ -72,7 +77,7 @@ func TestNilPersisterNoOps(t *testing.T) {
7277 }
7378
7479 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
75- dir := t.TempDir()
80+ dir := privateDir(t)
7681 p, err := Open(dir)
7782 require.NoError(t, err)
7883
@@ -108,7 +113,7 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
108113 }
109114
110115 func TestLoadReadOnlyUsesReplay(t *testing.T) {
111- dir := t.TempDir()
116+ dir := privateDir(t)
112117 p, err := Open(dir)
113118 require.NoError(t, err)
114119 require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
@@ -11,6 +11,11 @@ import (
11 portsmocks "github.com/bnema/vev/internal/ports/mocks"11 portsmocks "github.com/bnema/vev/internal/ports/mocks"
12 )12 )
13 13
14+func privateDir(t *testing.T) string {
15+ t.Helper()
16+ return filepath.Join(t.TempDir(), "vev")
17+}
18+
14 func TestRecordCodecRoundTrip(t *testing.T) {19 func TestRecordCodecRoundTrip(t *testing.T) {
15 r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}20 r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
16 value, err := encodeRecordValue(r)21 value, err := encodeRecordValue(r)
@@ -72,7 +77,7 @@ func TestNilPersisterNoOps(t *testing.T) {
72 }77 }
73 78
74 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {79 func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
75- dir := t.TempDir()80+ dir := privateDir(t)
76 p, err := Open(dir)81 p, err := Open(dir)
77 require.NoError(t, err)82 require.NoError(t, err)
78 83
@@ -108,7 +113,7 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
108 }113 }
109 114
110 func TestLoadReadOnlyUsesReplay(t *testing.T) {115 func TestLoadReadOnlyUsesReplay(t *testing.T) {
111- dir := t.TempDir()116+ dir := privateDir(t)
112 p, err := Open(dir)117 p, err := Open(dir)
113 require.NoError(t, err)118 require.NoError(t, err)
114 require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))119 require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
M internal/usecase/daemon/bar_refresh_test.go +29 -2
@@ -37,6 +37,27 @@ func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (
3737 return "", nil
3838 }
3939
40+type blockingBarRunner struct {
41+ *fakeBarRunner
42+ entered chan struct{}
43+ release chan struct{}
44+ once sync.Once
45+}
46+
47+func newBlockingBarRunner(outs []string) *blockingBarRunner {
48+ return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
49+}
50+
51+func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {
52+ r.once.Do(func() { close(r.entered) })
53+ select {
54+ case <-r.release:
55+ case <-ctx.Done():
56+ return "", ctx.Err()
57+ }
58+ return r.fakeBarRunner.run(ctx, command, barCtx)
59+}
60+
4061 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
4162 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
4263 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
@@ -219,13 +240,19 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
219240 }
220241
221242 func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222- r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
243+ r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
223244 d := newBarRefreshTestDaemon(r, time.Second)
224245 sess := newBarRefreshTestSession()
225246 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226247
227248 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
249+ select {
250+ case <-r.entered:
251+ case <-time.After(time.Second):
252+ t.Fatal("timeout waiting for first bar script run")
253+ }
228254 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
255+ close(r.release)
229256 waitBarRefreshIdle(t, d)
230257 require.Eventually(t, func() bool {
231258 r.mu.Lock()
@@ -275,7 +302,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
275302 require.Equal(t, "bottom-b", stateB.bottomRight)
276303 }
277304
278-func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
305+func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
279306 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
280307 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
281308 runner: r,
@@ -37,6 +37,27 @@ func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (
37 return "", nil37 return "", nil
38 }38 }
39 39
40+type blockingBarRunner struct {
41+ *fakeBarRunner
42+ entered chan struct{}
43+ release chan struct{}
44+ once sync.Once
45+}
46+
47+func newBlockingBarRunner(outs []string) *blockingBarRunner {
48+ return &blockingBarRunner{fakeBarRunner: &fakeBarRunner{outs: outs}, entered: make(chan struct{}), release: make(chan struct{})}
49+}
50+
51+func (r *blockingBarRunner) run(ctx context.Context, command string, barCtx barScriptContext) (string, error) {
52+ r.once.Do(func() { close(r.entered) })
53+ select {
54+ case <-r.release:
55+ case <-ctx.Done():
56+ return "", ctx.Err()
57+ }
58+ return r.fakeBarRunner.run(ctx, command, barCtx)
59+}
60+
40 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {61 func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) {
41 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}62 r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}}
42 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)63 d := newBarRefreshTestDaemon(r, 100*time.Millisecond)
@@ -219,13 +240,19 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
219 }240 }
220 241
221 func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {242 func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222- r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}243+ r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"})
223 d := newBarRefreshTestDaemon(r, time.Second)244 d := newBarRefreshTestDaemon(r, time.Second)
224 sess := newBarRefreshTestSession()245 sess := newBarRefreshTestSession()
225 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}246 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226 247
227 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))248 require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
249+ select {
250+ case <-r.entered:
251+ case <-time.After(time.Second):
252+ t.Fatal("timeout waiting for first bar script run")
253+ }
228 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))254 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
255+ close(r.release)
229 waitBarRefreshIdle(t, d)256 waitBarRefreshIdle(t, d)
230 require.Eventually(t, func() bool {257 require.Eventually(t, func() bool {
231 r.mu.Lock()258 r.mu.Lock()
@@ -275,7 +302,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
275 require.Equal(t, "bottom-b", stateB.bottomRight)302 require.Equal(t, "bottom-b", stateB.bottomRight)
276 }303 }
277 304
278-func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {305+func newBarRefreshTestDaemon(r barScriptExecutor, interval time.Duration) *Daemon {
279 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{306 d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
280 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},307 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
281 runner: r,308 runner: r,
M pkg/kv/kv.go +3 -1
@@ -11,6 +11,8 @@ import (
1111 "sort"
1212 "sync"
1313 "syscall"
14+
15+ "github.com/bnema/vev/pkg/safedir"
1416 )
1517
1618 // Store is an append-only WAL-backed key/value store.
@@ -29,7 +31,7 @@ type Store struct {
2931 // Open opens or creates a store at path, replays valid records, truncates any
3032 // corrupt or torn tail, and compacts if the file contains enough obsolete data.
3133 func Open(path string) (*Store, error) {
32- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
34+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
3335 return nil, err
3436 }
3537
@@ -11,6 +11,8 @@ import (
11 "sort"11 "sort"
12 "sync"12 "sync"
13 "syscall"13 "syscall"
14+
15+ "github.com/bnema/vev/pkg/safedir"
14 )16 )
15 17
16 // Store is an append-only WAL-backed key/value store.18 // Store is an append-only WAL-backed key/value store.
@@ -29,7 +31,7 @@ type Store struct {
29 // Open opens or creates a store at path, replays valid records, truncates any31 // Open opens or creates a store at path, replays valid records, truncates any
30 // corrupt or torn tail, and compacts if the file contains enough obsolete data.32 // corrupt or torn tail, and compacts if the file contains enough obsolete data.
31 func Open(path string) (*Store, error) {33 func Open(path string) (*Store, error) {
32- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {34+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
33 return nil, err35 return nil, err
34 }36 }
35 37
M pkg/kv/kv_test.go +21 -7
@@ -8,6 +8,11 @@ import (
88 "testing"
99 )
1010
11+func privatePath(t *testing.T) string {
12+ t.Helper()
13+ return filepath.Join(t.TempDir(), "vev", "store.kv")
14+}
15+
1116 func closeStore(t *testing.T, s *Store) {
1217 t.Helper()
1318 if err := s.Close(); err != nil {
@@ -16,7 +21,7 @@ func closeStore(t *testing.T, s *Store) {
1621 }
1722
1823 func TestSetGetDeleteRange(t *testing.T) {
19- path := filepath.Join(t.TempDir(), "store.kv")
24+ path := privatePath(t)
2025 s, err := Open(path)
2126 if err != nil {
2227 t.Fatal(err)
@@ -56,7 +61,7 @@ func TestSetGetDeleteRange(t *testing.T) {
5661 }
5762
5863 func TestReopenDurability(t *testing.T) {
59- path := filepath.Join(t.TempDir(), "store.kv")
64+ path := privatePath(t)
6065 s, err := Open(path)
6166 if err != nil {
6267 t.Fatal(err)
@@ -80,7 +85,7 @@ func TestReopenDurability(t *testing.T) {
8085 }
8186
8287 func TestCompactionShrinksWithDataIntact(t *testing.T) {
83- path := filepath.Join(t.TempDir(), "store.kv")
88+ path := privatePath(t)
8489 s, err := Open(path)
8590 if err != nil {
8691 t.Fatal(err)
@@ -114,9 +119,12 @@ func TestCompactionShrinksWithDataIntact(t *testing.T) {
114119 }
115120
116121 func TestOpenTruncatesMidRecordTail(t *testing.T) {
117- path := filepath.Join(t.TempDir(), "store.kv")
122+ path := privatePath(t)
118123 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119124 second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
125+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
126+ t.Fatal(err)
127+ }
120128 if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121129 t.Fatal(err)
122130 }
@@ -142,11 +150,14 @@ func TestOpenTruncatesMidRecordTail(t *testing.T) {
142150 }
143151
144152 func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145- path := filepath.Join(t.TempDir(), "store.kv")
153+ path := privatePath(t)
146154 first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147155 second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148156 buf := append(append([]byte{}, first...), second...)
149157 buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
158+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
159+ t.Fatal(err)
160+ }
150161 if err := os.WriteFile(path, buf, 0o600); err != nil {
151162 t.Fatal(err)
152163 }
@@ -195,7 +206,7 @@ func TestAbsentAndEmptyFile(t *testing.T) {
195206 }
196207
197208 func TestTombstoneSurvivesCompaction(t *testing.T) {
198- path := filepath.Join(t.TempDir(), "store.kv")
209+ path := privatePath(t)
199210 s, err := Open(path)
200211 if err != nil {
201212 t.Fatal(err)
@@ -227,10 +238,13 @@ func TestTombstoneSurvivesCompaction(t *testing.T) {
227238 }
228239
229240 func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230- path := filepath.Join(t.TempDir(), "store.kv")
241+ path := privatePath(t)
231242 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232243 header := make([]byte, headerLen)
233244 binary.BigEndian.PutUint32(header[0:4], 1<<31)
245+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
246+ t.Fatal(err)
247+ }
234248 if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235249 t.Fatal(err)
236250 }
@@ -8,6 +8,11 @@ import (
8 "testing"8 "testing"
9 )9 )
10 10
11+func privatePath(t *testing.T) string {
12+ t.Helper()
13+ return filepath.Join(t.TempDir(), "vev", "store.kv")
14+}
15+
11 func closeStore(t *testing.T, s *Store) {16 func closeStore(t *testing.T, s *Store) {
12 t.Helper()17 t.Helper()
13 if err := s.Close(); err != nil {18 if err := s.Close(); err != nil {
@@ -16,7 +21,7 @@ func closeStore(t *testing.T, s *Store) {
16 }21 }
17 22
18 func TestSetGetDeleteRange(t *testing.T) {23 func TestSetGetDeleteRange(t *testing.T) {
19- path := filepath.Join(t.TempDir(), "store.kv")24+ path := privatePath(t)
20 s, err := Open(path)25 s, err := Open(path)
21 if err != nil {26 if err != nil {
22 t.Fatal(err)27 t.Fatal(err)
@@ -56,7 +61,7 @@ func TestSetGetDeleteRange(t *testing.T) {
56 }61 }
57 62
58 func TestReopenDurability(t *testing.T) {63 func TestReopenDurability(t *testing.T) {
59- path := filepath.Join(t.TempDir(), "store.kv")64+ path := privatePath(t)
60 s, err := Open(path)65 s, err := Open(path)
61 if err != nil {66 if err != nil {
62 t.Fatal(err)67 t.Fatal(err)
@@ -80,7 +85,7 @@ func TestReopenDurability(t *testing.T) {
80 }85 }
81 86
82 func TestCompactionShrinksWithDataIntact(t *testing.T) {87 func TestCompactionShrinksWithDataIntact(t *testing.T) {
83- path := filepath.Join(t.TempDir(), "store.kv")88+ path := privatePath(t)
84 s, err := Open(path)89 s, err := Open(path)
85 if err != nil {90 if err != nil {
86 t.Fatal(err)91 t.Fatal(err)
@@ -114,9 +119,12 @@ func TestCompactionShrinksWithDataIntact(t *testing.T) {
114 }119 }
115 120
116 func TestOpenTruncatesMidRecordTail(t *testing.T) {121 func TestOpenTruncatesMidRecordTail(t *testing.T) {
117- path := filepath.Join(t.TempDir(), "store.kv")122+ path := privatePath(t)
118 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))123 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119 second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))124 second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
125+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
126+ t.Fatal(err)
127+ }
120 if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {128 if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121 t.Fatal(err)129 t.Fatal(err)
122 }130 }
@@ -142,11 +150,14 @@ func TestOpenTruncatesMidRecordTail(t *testing.T) {
142 }150 }
143 151
144 func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {152 func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145- path := filepath.Join(t.TempDir(), "store.kv")153+ path := privatePath(t)
146 first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))154 first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147 second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))155 second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148 buf := append(append([]byte{}, first...), second...)156 buf := append(append([]byte{}, first...), second...)
149 buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff157 buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
158+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
159+ t.Fatal(err)
160+ }
150 if err := os.WriteFile(path, buf, 0o600); err != nil {161 if err := os.WriteFile(path, buf, 0o600); err != nil {
151 t.Fatal(err)162 t.Fatal(err)
152 }163 }
@@ -195,7 +206,7 @@ func TestAbsentAndEmptyFile(t *testing.T) {
195 }206 }
196 207
197 func TestTombstoneSurvivesCompaction(t *testing.T) {208 func TestTombstoneSurvivesCompaction(t *testing.T) {
198- path := filepath.Join(t.TempDir(), "store.kv")209+ path := privatePath(t)
199 s, err := Open(path)210 s, err := Open(path)
200 if err != nil {211 if err != nil {
201 t.Fatal(err)212 t.Fatal(err)
@@ -227,10 +238,13 @@ func TestTombstoneSurvivesCompaction(t *testing.T) {
227 }238 }
228 239
229 func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {240 func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230- path := filepath.Join(t.TempDir(), "store.kv")241+ path := privatePath(t)
231 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))242 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232 header := make([]byte, headerLen)243 header := make([]byte, headerLen)
233 binary.BigEndian.PutUint32(header[0:4], 1<<31)244 binary.BigEndian.PutUint32(header[0:4], 1<<31)
245+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
246+ t.Fatal(err)
247+ }
234 if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {248 if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235 t.Fatal(err)249 t.Fatal(err)
236 }250 }
A pkg/safedir/safedir.go +39 -0
@@ -0,0 +1,39 @@
1+// Package safedir creates private directories and refuses to trust
2+// pre-existing ones that another user could control.
3+package safedir
4+
5+import (
6+ "fmt"
7+ "os"
8+ "syscall"
9+)
10+
11+// EnsurePrivate creates dir with mode 0700 if missing, then verifies it is a
12+// real directory (not a symlink) owned by the current user and has exact 0700
13+// permissions. It returns an error describing the violation otherwise.
14+func EnsurePrivate(dir string) error {
15+ if err := os.MkdirAll(dir, 0o700); err != nil {
16+ return err
17+ }
18+ fi, err := os.Lstat(dir)
19+ if err != nil {
20+ return err
21+ }
22+ if fi.Mode()&os.ModeSymlink != 0 {
23+ return fmt.Errorf("safedir: %s is a symlink", dir)
24+ }
25+ if !fi.IsDir() {
26+ return fmt.Errorf("safedir: %s is not a directory", dir)
27+ }
28+ st, ok := fi.Sys().(*syscall.Stat_t)
29+ if !ok {
30+ return fmt.Errorf("safedir: cannot stat %s", dir)
31+ }
32+ if int(st.Uid) != os.Getuid() {
33+ return fmt.Errorf("safedir: %s is owned by uid %d, not current uid %d — refusing to use it", dir, st.Uid, os.Getuid())
34+ }
35+ if perm := fi.Mode().Perm(); perm != 0o700 {
36+ return fmt.Errorf("safedir: %s has permissions %04o, want 0700 — refusing to use it", dir, perm)
37+ }
38+ return nil
39+}
@@ -0,0 +1,39 @@
1+// Package safedir creates private directories and refuses to trust
2+// pre-existing ones that another user could control.
3+package safedir
4+
5+import (
6+ "fmt"
7+ "os"
8+ "syscall"
9+)
10+
11+// EnsurePrivate creates dir with mode 0700 if missing, then verifies it is a
12+// real directory (not a symlink) owned by the current user and has exact 0700
13+// permissions. It returns an error describing the violation otherwise.
14+func EnsurePrivate(dir string) error {
15+ if err := os.MkdirAll(dir, 0o700); err != nil {
16+ return err
17+ }
18+ fi, err := os.Lstat(dir)
19+ if err != nil {
20+ return err
21+ }
22+ if fi.Mode()&os.ModeSymlink != 0 {
23+ return fmt.Errorf("safedir: %s is a symlink", dir)
24+ }
25+ if !fi.IsDir() {
26+ return fmt.Errorf("safedir: %s is not a directory", dir)
27+ }
28+ st, ok := fi.Sys().(*syscall.Stat_t)
29+ if !ok {
30+ return fmt.Errorf("safedir: cannot stat %s", dir)
31+ }
32+ if int(st.Uid) != os.Getuid() {
33+ return fmt.Errorf("safedir: %s is owned by uid %d, not current uid %d — refusing to use it", dir, st.Uid, os.Getuid())
34+ }
35+ if perm := fi.Mode().Perm(); perm != 0o700 {
36+ return fmt.Errorf("safedir: %s has permissions %04o, want 0700 — refusing to use it", dir, perm)
37+ }
38+ return nil
39+}
A pkg/safedir/safedir_test.go +150 -0
@@ -0,0 +1,150 @@
1+package safedir
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestEnsurePrivate(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ setup func(t *testing.T, path string)
14+ wantErr string
15+ }{
16+ {
17+ name: "missing dir is created private",
18+ },
19+ {
20+ name: "existing private dir is accepted",
21+ setup: func(t *testing.T, path string) {
22+ t.Helper()
23+ if err := os.MkdirAll(path, 0o700); err != nil {
24+ t.Fatalf("mkdir private dir: %v", err)
25+ }
26+ },
27+ },
28+ {
29+ name: "group writable dir is rejected",
30+ setup: func(t *testing.T, path string) {
31+ t.Helper()
32+ if err := os.MkdirAll(path, 0o700); err != nil {
33+ t.Fatalf("mkdir group writable dir: %v", err)
34+ }
35+ if err := os.Chmod(path, 0o770); err != nil {
36+ t.Fatalf("chmod group writable dir: %v", err)
37+ }
38+ },
39+ wantErr: "permissions 0770, want 0700",
40+ },
41+ {
42+ name: "world writable dir is rejected",
43+ setup: func(t *testing.T, path string) {
44+ t.Helper()
45+ if err := os.MkdirAll(path, 0o700); err != nil {
46+ t.Fatalf("mkdir world writable dir: %v", err)
47+ }
48+ if err := os.Chmod(path, 0o707); err != nil {
49+ t.Fatalf("chmod world writable dir: %v", err)
50+ }
51+ },
52+ wantErr: "permissions 0707, want 0700",
53+ },
54+ {
55+ name: "world readable dir is rejected",
56+ setup: func(t *testing.T, path string) {
57+ t.Helper()
58+ if err := os.MkdirAll(path, 0o700); err != nil {
59+ t.Fatalf("mkdir world readable dir: %v", err)
60+ }
61+ if err := os.Chmod(path, 0o755); err != nil {
62+ t.Fatalf("chmod world readable dir: %v", err)
63+ }
64+ },
65+ wantErr: "permissions 0755, want 0700",
66+ },
67+ {
68+ name: "execute-only others dir is rejected",
69+ setup: func(t *testing.T, path string) {
70+ t.Helper()
71+ if err := os.MkdirAll(path, 0o700); err != nil {
72+ t.Fatalf("mkdir execute-only others dir: %v", err)
73+ }
74+ if err := os.Chmod(path, 0o711); err != nil {
75+ t.Fatalf("chmod execute-only others dir: %v", err)
76+ }
77+ },
78+ wantErr: "permissions 0711, want 0700",
79+ },
80+ {
81+ name: "owner read execute dir is rejected",
82+ setup: func(t *testing.T, path string) {
83+ t.Helper()
84+ if err := os.MkdirAll(path, 0o700); err != nil {
85+ t.Fatalf("mkdir owner read execute dir: %v", err)
86+ }
87+ if err := os.Chmod(path, 0o500); err != nil {
88+ t.Fatalf("chmod owner read execute dir: %v", err)
89+ }
90+ },
91+ wantErr: "permissions 0500, want 0700",
92+ },
93+ {
94+ name: "symlink is rejected",
95+ setup: func(t *testing.T, path string) {
96+ t.Helper()
97+ if err := os.Symlink(t.TempDir(), path); err != nil {
98+ t.Fatalf("symlink dir: %v", err)
99+ }
100+ },
101+ wantErr: "symlink",
102+ },
103+ {
104+ name: "regular file is rejected",
105+ setup: func(t *testing.T, path string) {
106+ t.Helper()
107+ if err := os.WriteFile(path, []byte("not a dir"), 0o600); err != nil {
108+ t.Fatalf("write file: %v", err)
109+ }
110+ },
111+ wantErr: "not a directory",
112+ },
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "private")
118+ if tt.setup != nil {
119+ tt.setup(t, path)
120+ }
121+
122+ err := EnsurePrivate(path)
123+ if tt.wantErr != "" {
124+ if err == nil {
125+ t.Fatalf("EnsurePrivate() error = nil, want containing %q", tt.wantErr)
126+ }
127+ if !strings.Contains(err.Error(), tt.wantErr) {
128+ t.Fatalf("EnsurePrivate() error = %q, want containing %q", err, tt.wantErr)
129+ }
130+ return
131+ }
132+ if err != nil {
133+ t.Fatalf("EnsurePrivate() error = %v", err)
134+ }
135+ fi, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatalf("stat private dir: %v", err)
138+ }
139+ if !fi.IsDir() {
140+ t.Fatalf("%s is not a directory", path)
141+ }
142+ if perm := fi.Mode().Perm(); perm != 0o700 {
143+ t.Fatalf("dir perm = %o, want 0700", perm)
144+ }
145+ })
146+ }
147+}
148+
149+// A wrong-uid directory is not covered here because creating one requires
150+// elevated privileges; EnsurePrivate checks the uid returned by Lstat.
@@ -0,0 +1,150 @@
1+package safedir
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestEnsurePrivate(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ setup func(t *testing.T, path string)
14+ wantErr string
15+ }{
16+ {
17+ name: "missing dir is created private",
18+ },
19+ {
20+ name: "existing private dir is accepted",
21+ setup: func(t *testing.T, path string) {
22+ t.Helper()
23+ if err := os.MkdirAll(path, 0o700); err != nil {
24+ t.Fatalf("mkdir private dir: %v", err)
25+ }
26+ },
27+ },
28+ {
29+ name: "group writable dir is rejected",
30+ setup: func(t *testing.T, path string) {
31+ t.Helper()
32+ if err := os.MkdirAll(path, 0o700); err != nil {
33+ t.Fatalf("mkdir group writable dir: %v", err)
34+ }
35+ if err := os.Chmod(path, 0o770); err != nil {
36+ t.Fatalf("chmod group writable dir: %v", err)
37+ }
38+ },
39+ wantErr: "permissions 0770, want 0700",
40+ },
41+ {
42+ name: "world writable dir is rejected",
43+ setup: func(t *testing.T, path string) {
44+ t.Helper()
45+ if err := os.MkdirAll(path, 0o700); err != nil {
46+ t.Fatalf("mkdir world writable dir: %v", err)
47+ }
48+ if err := os.Chmod(path, 0o707); err != nil {
49+ t.Fatalf("chmod world writable dir: %v", err)
50+ }
51+ },
52+ wantErr: "permissions 0707, want 0700",
53+ },
54+ {
55+ name: "world readable dir is rejected",
56+ setup: func(t *testing.T, path string) {
57+ t.Helper()
58+ if err := os.MkdirAll(path, 0o700); err != nil {
59+ t.Fatalf("mkdir world readable dir: %v", err)
60+ }
61+ if err := os.Chmod(path, 0o755); err != nil {
62+ t.Fatalf("chmod world readable dir: %v", err)
63+ }
64+ },
65+ wantErr: "permissions 0755, want 0700",
66+ },
67+ {
68+ name: "execute-only others dir is rejected",
69+ setup: func(t *testing.T, path string) {
70+ t.Helper()
71+ if err := os.MkdirAll(path, 0o700); err != nil {
72+ t.Fatalf("mkdir execute-only others dir: %v", err)
73+ }
74+ if err := os.Chmod(path, 0o711); err != nil {
75+ t.Fatalf("chmod execute-only others dir: %v", err)
76+ }
77+ },
78+ wantErr: "permissions 0711, want 0700",
79+ },
80+ {
81+ name: "owner read execute dir is rejected",
82+ setup: func(t *testing.T, path string) {
83+ t.Helper()
84+ if err := os.MkdirAll(path, 0o700); err != nil {
85+ t.Fatalf("mkdir owner read execute dir: %v", err)
86+ }
87+ if err := os.Chmod(path, 0o500); err != nil {
88+ t.Fatalf("chmod owner read execute dir: %v", err)
89+ }
90+ },
91+ wantErr: "permissions 0500, want 0700",
92+ },
93+ {
94+ name: "symlink is rejected",
95+ setup: func(t *testing.T, path string) {
96+ t.Helper()
97+ if err := os.Symlink(t.TempDir(), path); err != nil {
98+ t.Fatalf("symlink dir: %v", err)
99+ }
100+ },
101+ wantErr: "symlink",
102+ },
103+ {
104+ name: "regular file is rejected",
105+ setup: func(t *testing.T, path string) {
106+ t.Helper()
107+ if err := os.WriteFile(path, []byte("not a dir"), 0o600); err != nil {
108+ t.Fatalf("write file: %v", err)
109+ }
110+ },
111+ wantErr: "not a directory",
112+ },
113+ }
114+
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "private")
118+ if tt.setup != nil {
119+ tt.setup(t, path)
120+ }
121+
122+ err := EnsurePrivate(path)
123+ if tt.wantErr != "" {
124+ if err == nil {
125+ t.Fatalf("EnsurePrivate() error = nil, want containing %q", tt.wantErr)
126+ }
127+ if !strings.Contains(err.Error(), tt.wantErr) {
128+ t.Fatalf("EnsurePrivate() error = %q, want containing %q", err, tt.wantErr)
129+ }
130+ return
131+ }
132+ if err != nil {
133+ t.Fatalf("EnsurePrivate() error = %v", err)
134+ }
135+ fi, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatalf("stat private dir: %v", err)
138+ }
139+ if !fi.IsDir() {
140+ t.Fatalf("%s is not a directory", path)
141+ }
142+ if perm := fi.Mode().Perm(); perm != 0o700 {
143+ t.Fatalf("dir perm = %o, want 0700", perm)
144+ }
145+ })
146+ }
147+}
148+
149+// A wrong-uid directory is not covered here because creating one requires
150+// elevated privileges; EnsurePrivate checks the uid returned by Lstat.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close