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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 58 | 58 | _ = p.Client.Close() | |
| 59 | 59 | _ = p.Daemon.Close() | |
| 60 | 60 | }() | |
| 61 | + | copyCtx, cancelCopies := context.WithCancel(ctx) | |
| 62 | + | defer cancelCopies() | |
| 61 | 63 | ||
| 62 | 64 | idleTTL := p.IdleTTL | |
| 63 | 65 | if idleTTL <= 0 { | |
| @@ -73,7 +75,7 @@ func (p ProxyRuntime) Run(ctx context.Context) error { | |||
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 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 | 79 | clientToDaemon := proxyCopyDirection{src: p.Client, dst: p.Daemon, recvKind: proxyClientRecv, sendKind: proxyDaemonSend} | |
| 78 | 80 | daemonToClient := proxyCopyDirection{src: p.Daemon, dst: p.Client, recvKind: proxyDaemonRecv, sendKind: proxyClientSend, retryRecoverable: true} | |
| 79 | 81 | 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.IdleTTL | 64 | 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 ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/pkg/safedir" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | type ProxyRecord struct { | |
| @@ -94,7 +96,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error { | |||
| 94 | 96 | } | |
| 95 | 97 | ||
| 96 | 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 | 100 | return err | |
| 99 | 101 | } | |
| 100 | 102 | 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 err | 100 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | + | "path/filepath" | |
| 5 | 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 | 14 | func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |
| 9 | 15 | t.Run("reuses live matching record", func(t *testing.T) { | |
| 10 | - | r := NewProxyRegistry(t.TempDir()) | |
| 16 | + | r := NewProxyRegistry(privateDir(t)) | |
| 11 | 17 | want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"} | |
| 12 | 18 | if err := r.Publish(want); err != nil { | |
| 13 | 19 | t.Fatal(err) | |
| @@ -21,7 +27,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 21 | 27 | } | |
| 22 | 28 | }) | |
| 23 | 29 | t.Run("removes stale record", func(t *testing.T) { | |
| 24 | - | r := NewProxyRegistry(t.TempDir()) | |
| 30 | + | r := NewProxyRegistry(privateDir(t)) | |
| 25 | 31 | if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil { | |
| 26 | 32 | t.Fatal(err) | |
| 27 | 33 | } | |
| @@ -36,7 +42,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 36 | 42 | } | |
| 37 | 43 | }) | |
| 38 | 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 | 46 | old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"} | |
| 41 | 47 | if err := r.Publish(old); err != nil { | |
| 42 | 48 | t.Fatal(err) | |
| @@ -65,7 +71,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 65 | 71 | } | |
| 66 | 72 | ||
| 67 | 73 | func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) { | |
| 68 | - | r := NewProxyRegistry(t.TempDir()) | |
| 74 | + | r := NewProxyRegistry(privateDir(t)) | |
| 69 | 75 | tests := []struct { | |
| 70 | 76 | a string | |
| 71 | 77 | 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 string | 76 | a string | ||
| 71 | b string | 77 | b string | ||
M internal/adapters/dgram/proxy_test.go +57 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "sync" | |
| @@ -215,3 +216,59 @@ func TestProxyRuntimeClosedLinkEventsDoNotSpin(t *testing.T) { | |||
| 215 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package dgram | 1 | 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) { | |||
| 238 | 238 | type deadlineCapturePC struct { | |
| 239 | 239 | addr net.Addr | |
| 240 | 240 | deadline atomic.Value | |
| 241 | + | done chan struct{} | |
| 241 | 242 | } | |
| 242 | 243 | ||
| 243 | 244 | func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) { | |
| 245 | + | <-p.done | |
| 244 | 246 | return 0, nil, errors.New("closed") | |
| 245 | 247 | } | |
| 246 | 248 | 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 } | |
| 251 | 260 | func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error { | |
| 252 | 261 | if !t.IsZero() { | |
| 253 | 262 | p.deadline.Store(t) | |
| @@ -773,7 +782,7 @@ func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) { | |||
| 773 | 782 | ||
| 774 | 783 | func TestWritePacketDeadlineUsesTransportClock(t *testing.T) { | |
| 775 | 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 | 786 | tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{ | |
| 778 | 787 | Clock: fixedClock{now: wantNow}, | |
| 779 | 788 | 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.Addr | 239 | addr net.Addr | ||
| 240 | deadline atomic.Value | 240 | 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 ( | |||
| 9 | 9 | "syscall" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // socketFileName is the fixed name of the daemon's listening socket within | |
| @@ -35,8 +36,8 @@ type unixListener struct { | |||
| 35 | 36 | // unlinked and bind is retried once. A successful dial means a live daemon | |
| 36 | 37 | // owns the socket, and Listen returns ErrDaemonRunning. | |
| 37 | 38 | 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) | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | 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 within | 15 | // 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 daemon | 36 | // 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 ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strconv" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | func TestListenAcceptRoundTrip(t *testing.T) { | |
| 15 | - | dir := t.TempDir() | |
| 16 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 16 | 17 | ||
| 17 | 18 | ln, err := Listen(dir) | |
| 18 | 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 | 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 | 113 | sockPath := filepath.Join(dir, "daemon.sock") | |
| 91 | 114 | ||
| 92 | 115 | // 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 | 142 | func TestListenLiveDaemonRejected(t *testing.T) { | |
| 120 | - | dir := t.TempDir() | |
| 143 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 121 | 144 | ||
| 122 | 145 | first, err := Listen(dir) | |
| 123 | 146 | 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, then | 115 | // 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 ( | |||
| 13 | 13 | "strings" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/pkg/safedir" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) | |
| @@ -35,12 +36,9 @@ func NewStore(dir string) *Store { | |||
| 35 | 36 | // Write atomically writes data for name. The previous snapshot remains in place | |
| 36 | 37 | // until the final rename succeeds. | |
| 37 | 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 | 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 | 43 | tmp, err := os.CreateTemp(s.dir, ".tmp-*") | |
| 46 | 44 | 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 place | 36 | // 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 ( | |||
| 12 | 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 | 20 | func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |
| 16 | 21 | t.Parallel() | |
| 17 | - | dir := t.TempDir() | |
| 22 | + | dir := privateDir(t) | |
| 18 | 23 | store := NewStore(dir) | |
| 19 | 24 | ||
| 20 | 25 | if err := store.Write("alpha", []byte("first")); err != nil { | |
| @@ -44,7 +49,7 @@ func TestStoreWriteLoadRoundTripAndSupersede(t *testing.T) { | |||
| 44 | 49 | ||
| 45 | 50 | func TestStoreDelete(t *testing.T) { | |
| 46 | 51 | t.Parallel() | |
| 47 | - | dir := t.TempDir() | |
| 52 | + | dir := privateDir(t) | |
| 48 | 53 | store := NewStore(dir) | |
| 49 | 54 | ||
| 50 | 55 | if err := store.Write("gone", []byte("data")); err != nil { | |
| @@ -63,7 +68,7 @@ func TestStoreDelete(t *testing.T) { | |||
| 63 | 68 | ||
| 64 | 69 | func TestStoreUnsafeNameUsesDeterministicHash(t *testing.T) { | |
| 65 | 70 | t.Parallel() | |
| 66 | - | dir := t.TempDir() | |
| 71 | + | dir := privateDir(t) | |
| 67 | 72 | store := NewStore(dir) | |
| 68 | 73 | name := "../unsafe/session name" | |
| 69 | 74 | ||
| @@ -120,8 +125,11 @@ func TestStoreMissingDirAndStaleTmpCleanup(t *testing.T) { | |||
| 120 | 125 | ||
| 121 | 126 | func TestStoreLoadRejectsOversizedFileBeforeRead(t *testing.T) { | |
| 122 | 127 | t.Parallel() | |
| 123 | - | dir := t.TempDir() | |
| 128 | + | dir := privateDir(t) | |
| 124 | 129 | store := NewStore(dir) | |
| 130 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 131 | + | t.Fatalf("MkdirAll: %v", err) | |
| 132 | + | } | |
| 125 | 133 | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 126 | 134 | t.Fatalf("write ok: %v", err) | |
| 127 | 135 | } | |
| @@ -149,8 +157,11 @@ func TestStoreLoadSkipsUnreadableFile(t *testing.T) { | |||
| 149 | 157 | t.Skip("permission bits do not make file unreadable on this platform/user") | |
| 150 | 158 | } | |
| 151 | 159 | t.Parallel() | |
| 152 | - | dir := t.TempDir() | |
| 160 | + | dir := privateDir(t) | |
| 153 | 161 | store := NewStore(dir) | |
| 162 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 163 | + | t.Fatalf("MkdirAll: %v", err) | |
| 164 | + | } | |
| 154 | 165 | if err := os.WriteFile(filepath.Join(dir, "ok.snap"), []byte("ok"), 0o600); err != nil { | |
| 155 | 166 | t.Fatalf("write ok: %v", err) | |
| 156 | 167 | } | |
| @@ -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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "log/slog" | |
| 10 | + | "path/filepath" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| @@ -35,7 +36,7 @@ func discardLog() *slog.Logger { | |||
| 35 | 36 | // The returned served channel receives Serve's result exactly once. | |
| 36 | 37 | func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) { | |
| 37 | 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 | 42 | 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 ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 15 | 15 | "github.com/bnema/vev/internal/platform" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | + | "github.com/bnema/vev/pkg/safedir" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | // 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 | 89 | // no-op release) when another process holds a fresh lock, and takes over a | |
| 89 | 90 | // lock older than staleLockAge (crash resilience). | |
| 90 | 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 | 93 | return nil, false, err | |
| 93 | 94 | } | |
| 94 | 95 | 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 becomes | 20 | // 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 a | 89 | // 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, err | 93 | 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") | |||
| 20 | 20 | var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond} | |
| 21 | 21 | ||
| 22 | 22 | func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |
| 23 | - | dir := t.TempDir() | |
| 23 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 24 | 24 | const racers = 16 | |
| 25 | 25 | ||
| 26 | 26 | var wins atomic.Int32 | |
| @@ -62,7 +62,10 @@ func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 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 | 69 | lockPath := filepath.Join(dir, spawnLockName) | |
| 67 | 70 | if err := os.Mkdir(lockPath, 0o700); err != nil { | |
| 68 | 71 | t.Fatalf("seeding lock: %v", err) | |
| @@ -126,7 +129,7 @@ func TestRetryDialContextCancelled(t *testing.T) { | |||
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | 131 | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |
| 129 | - | dir := t.TempDir() | |
| 132 | + | dir := filepath.Join(t.TempDir(), "vev") | |
| 130 | 133 | want := portsmocks.NewMockTransport(t) | |
| 131 | 134 | var dialCalls, spawnCalls atomic.Int32 | |
| 132 | 135 | dial := func(string) (ports.Transport, error) { | |
| @@ -180,7 +183,7 @@ func TestEnsureDaemonSpawnFailure(t *testing.T) { | |||
| 180 | 183 | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |
| 181 | 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 | 187 | if !errors.Is(err, spawnErr) { | |
| 185 | 188 | t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr) | |
| 186 | 189 | } | |
| @@ -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 = 16 | 24 | const racers = 16 | ||
| 25 | 25 | ||||
| 26 | var wins atomic.Int32 | 26 | 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.Int32 | 134 | 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 ( | |||
| 8 | 8 | "runtime/debug" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "sync" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/pkg/safedir" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | type Component string | |
| @@ -29,7 +31,7 @@ type Config struct { | |||
| 29 | 31 | } | |
| 30 | 32 | ||
| 31 | 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 | 35 | return nil, nil, err | |
| 34 | 36 | } | |
| 35 | 37 | ||
| @@ -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 string | 15 | 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, err | 35 | return nil, nil, err | ||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
M internal/logging/logging_test.go +8 -3
| @@ -11,6 +11,11 @@ import ( | |||
| 11 | 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 | 19 | func TestParseLevelAndEnvLevel(t *testing.T) { | |
| 15 | 20 | tests := []struct { | |
| 16 | 21 | in string | |
| @@ -38,7 +43,7 @@ func TestParseLevelAndEnvLevel(t *testing.T) { | |||
| 38 | 43 | } | |
| 39 | 44 | ||
| 40 | 45 | func TestSetupWritesJSONWithComponent(t *testing.T) { | |
| 41 | - | dir := t.TempDir() | |
| 46 | + | dir := privateDir(t) | |
| 42 | 47 | logger, closer, err := Setup(Config{Dir: dir, Component: Client, Level: slog.LevelDebug}) | |
| 43 | 48 | if err != nil { | |
| 44 | 49 | t.Fatalf("Setup() error = %v", err) | |
| @@ -143,7 +148,7 @@ func TestStartupOnlyRotation(t *testing.T) { | |||
| 143 | 148 | } | |
| 144 | 149 | ||
| 145 | 150 | func TestCrashOutputCapturesPanicStack(t *testing.T) { | |
| 146 | - | dir := t.TempDir() | |
| 151 | + | dir := privateDir(t) | |
| 147 | 152 | cmd := exec.Command(os.Args[0], "-test.run=TestCrashOutputHelper") | |
| 148 | 153 | cmd.Env = append(os.Environ(), "VEV_CRASH_HELPER=1", "VEV_CRASH_DIR="+dir) | |
| 149 | 154 | if err := cmd.Run(); err == nil { | |
| @@ -211,7 +216,7 @@ func TestStartupOnlyConcurrentRotationRaceIsIgnored(t *testing.T) { | |||
| 211 | 216 | } | |
| 212 | 217 | ||
| 213 | 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 | 220 | if err != nil { | |
| 216 | 221 | t.Fatalf("Setup() error = %v", err) | |
| 217 | 222 | } | |
| @@ -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 string | 21 | 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 ( | |||
| 11 | 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 | 19 | func TestRecordCodecRoundTrip(t *testing.T) { | |
| 15 | 20 | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33} | |
| 16 | 21 | value, err := encodeRecordValue(r) | |
| @@ -72,7 +77,7 @@ func TestNilPersisterNoOps(t *testing.T) { | |||
| 72 | 77 | } | |
| 73 | 78 | ||
| 74 | 79 | func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |
| 75 | - | dir := t.TempDir() | |
| 80 | + | dir := privateDir(t) | |
| 76 | 81 | p, err := Open(dir) | |
| 77 | 82 | require.NoError(t, err) | |
| 78 | 83 | ||
| @@ -108,7 +113,7 @@ func TestDecodeAllSkipsMalformedRecords(t *testing.T) { | |||
| 108 | 113 | } | |
| 109 | 114 | ||
| 110 | 115 | func TestLoadReadOnlyUsesReplay(t *testing.T) { | |
| 111 | - | dir := t.TempDir() | |
| 116 | + | dir := privateDir(t) | |
| 112 | 117 | p, err := Open(dir) | |
| 113 | 118 | require.NoError(t, err) | |
| 114 | 119 | 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) ( | |||
| 37 | 37 | 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 | 61 | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |
| 41 | 62 | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 42 | 63 | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| @@ -219,13 +240,19 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||
| 219 | 240 | } | |
| 220 | 241 | ||
| 221 | 242 | func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) { | |
| 222 | - | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 243 | + | r := newBlockingBarRunner([]string{"top1", "bottom1", "top2", "bottom2"}) | |
| 223 | 244 | d := newBarRefreshTestDaemon(r, time.Second) | |
| 224 | 245 | sess := newBarRefreshTestSession() | |
| 225 | 246 | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 226 | 247 | ||
| 227 | 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 | 254 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true)) | |
| 255 | + | close(r.release) | |
| 229 | 256 | waitBarRefreshIdle(t, d) | |
| 230 | 257 | require.Eventually(t, func() bool { | |
| 231 | 258 | r.mu.Lock() | |
| @@ -275,7 +302,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 275 | 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 | 306 | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |
| 280 | 307 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| 281 | 308 | runner: r, | |
| @@ -37,6 +37,27 @@ func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) ( | |||||
| 37 | return "", nil | 37 | 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 ( | |||
| 11 | 11 | "sort" | |
| 12 | 12 | "sync" | |
| 13 | 13 | "syscall" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | // Store is an append-only WAL-backed key/value store. | |
| @@ -29,7 +31,7 @@ type Store struct { | |||
| 29 | 31 | // Open opens or creates a store at path, replays valid records, truncates any | |
| 30 | 32 | // corrupt or torn tail, and compacts if the file contains enough obsolete data. | |
| 31 | 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 | 35 | return nil, err | |
| 34 | 36 | } | |
| 35 | 37 | ||
| @@ -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 any | 31 | // 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, err | 35 | return nil, err | ||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
M pkg/kv/kv_test.go +21 -7
| @@ -8,6 +8,11 @@ import ( | |||
| 8 | 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 | 16 | func closeStore(t *testing.T, s *Store) { | |
| 12 | 17 | t.Helper() | |
| 13 | 18 | if err := s.Close(); err != nil { | |
| @@ -16,7 +21,7 @@ func closeStore(t *testing.T, s *Store) { | |||
| 16 | 21 | } | |
| 17 | 22 | ||
| 18 | 23 | func TestSetGetDeleteRange(t *testing.T) { | |
| 19 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 24 | + | path := privatePath(t) | |
| 20 | 25 | s, err := Open(path) | |
| 21 | 26 | if err != nil { | |
| 22 | 27 | t.Fatal(err) | |
| @@ -56,7 +61,7 @@ func TestSetGetDeleteRange(t *testing.T) { | |||
| 56 | 61 | } | |
| 57 | 62 | ||
| 58 | 63 | func TestReopenDurability(t *testing.T) { | |
| 59 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 64 | + | path := privatePath(t) | |
| 60 | 65 | s, err := Open(path) | |
| 61 | 66 | if err != nil { | |
| 62 | 67 | t.Fatal(err) | |
| @@ -80,7 +85,7 @@ func TestReopenDurability(t *testing.T) { | |||
| 80 | 85 | } | |
| 81 | 86 | ||
| 82 | 87 | func TestCompactionShrinksWithDataIntact(t *testing.T) { | |
| 83 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 88 | + | path := privatePath(t) | |
| 84 | 89 | s, err := Open(path) | |
| 85 | 90 | if err != nil { | |
| 86 | 91 | t.Fatal(err) | |
| @@ -114,9 +119,12 @@ func TestCompactionShrinksWithDataIntact(t *testing.T) { | |||
| 114 | 119 | } | |
| 115 | 120 | ||
| 116 | 121 | func TestOpenTruncatesMidRecordTail(t *testing.T) { | |
| 117 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 122 | + | path := privatePath(t) | |
| 118 | 123 | first, _ := encodeRecord(opSet, []byte("a"), []byte("one")) | |
| 119 | 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 | 128 | if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil { | |
| 121 | 129 | t.Fatal(err) | |
| 122 | 130 | } | |
| @@ -142,11 +150,14 @@ func TestOpenTruncatesMidRecordTail(t *testing.T) { | |||
| 142 | 150 | } | |
| 143 | 151 | ||
| 144 | 152 | func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) { | |
| 145 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 153 | + | path := privatePath(t) | |
| 146 | 154 | first, _ := encodeRecord(opSet, []byte("k"), []byte("old")) | |
| 147 | 155 | second, _ := encodeRecord(opSet, []byte("k"), []byte("new")) | |
| 148 | 156 | buf := append(append([]byte{}, first...), second...) | |
| 149 | 157 | buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff | |
| 158 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 150 | 161 | if err := os.WriteFile(path, buf, 0o600); err != nil { | |
| 151 | 162 | t.Fatal(err) | |
| 152 | 163 | } | |
| @@ -195,7 +206,7 @@ func TestAbsentAndEmptyFile(t *testing.T) { | |||
| 195 | 206 | } | |
| 196 | 207 | ||
| 197 | 208 | func TestTombstoneSurvivesCompaction(t *testing.T) { | |
| 198 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 209 | + | path := privatePath(t) | |
| 199 | 210 | s, err := Open(path) | |
| 200 | 211 | if err != nil { | |
| 201 | 212 | t.Fatal(err) | |
| @@ -227,10 +238,13 @@ func TestTombstoneSurvivesCompaction(t *testing.T) { | |||
| 227 | 238 | } | |
| 228 | 239 | ||
| 229 | 240 | func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) { | |
| 230 | - | path := filepath.Join(t.TempDir(), "store.kv") | |
| 241 | + | path := privatePath(t) | |
| 231 | 242 | first, _ := encodeRecord(opSet, []byte("a"), []byte("one")) | |
| 232 | 243 | header := make([]byte, headerLen) | |
| 233 | 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 | 248 | if err := os.WriteFile(path, append(first, header...), 0o600); err != nil { | |
| 235 | 249 | t.Fatal(err) | |
| 236 | 250 | } | |
| @@ -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] ^= 0xff | 157 | 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. | |||