1package uidriver
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "net"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/pkg/safedir"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21)
22
23func TestValidateSocketPathUsesPlatformLimit(t *testing.T) {
24 const (
25 expectedDarwinSocketMax = 103
26 expectedLinuxSocketMax = 107
27 )
28 expectedMax := expectedLinuxSocketMax
29 switch runtime.GOOS {
30 case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
31 expectedMax = expectedDarwinSocketMax
32 }
33 tests := []struct {
34 name string
35 path string
36 want bool
37 }{
38 {name: "at limit", path: "/" + strings.Repeat("a", expectedMax-1), want: true},
39 {name: "above limit", path: "/" + strings.Repeat("a", expectedMax), want: false},
40 }
41 for _, test := range tests {
42 t.Run(test.name, func(t *testing.T) {
43 err := validateSocketPath(test.path)
44 if test.want {
45 require.NoError(t, err)
46 } else {
47 require.Error(t, err)
48 }
49 })
50 }
51}
52
53func TestListenUnixServesPrivateAttachmentAndCleansOwnedSocket(t *testing.T) {
54 service := portsmocks.NewMockUIService(t)
55 service.EXPECT().Capture("attachment").Return(testSnapshot(), nil).Once()
56 directory := shortDir(t)
57 path := filepath.Join(directory, "ui.sock")
58 server := New(service, nil)
59 endpoint, err := ListenUnix(path, server, func() Ready {
60 return testReady(false)
61 })
62 require.NoError(t, err)
63 require.Equal(t, os.FileMode(0o600), fileMode(t, path))
64 defer func() { _ = endpoint.Close() }()
65
66 dialer := &net.Dialer{Timeout: time.Second}
67 conn, err := dialer.DialContext(context.Background(), "unix", path)
68 require.NoError(t, err)
69 defer func() { _ = conn.Close() }()
70 decoder := json.NewDecoder(bufio.NewReader(conn))
71 var ready envelope
72 require.NoError(t, decoder.Decode(&ready))
73 require.Zero(t, ready.ID)
74 _, err = conn.Write([]byte(`{"version":1,"id":1,"op":"capture","attachment":"attachment"}` + "\n"))
75 require.NoError(t, err)
76 var response envelope
77 require.NoError(t, decoder.Decode(&response))
78 require.Equal(t, uint64(1), response.ID)
79 require.Nil(t, response.Error)
80
81 require.NoError(t, endpoint.Close())
82 _, err = os.Lstat(path)
83 require.ErrorIs(t, err, os.ErrNotExist)
84}
85
86func TestListenUnixRejectsUnsafeParentSymlink(t *testing.T) {
87 root := shortDir(t)
88 target := filepath.Join(root, "target")
89 require.NoError(t, os.Mkdir(target, 0o700))
90 link := filepath.Join(root, "link")
91 require.NoError(t, os.Symlink(target, link))
92 _, err := ListenUnix(filepath.Join(link, "ui.sock"), New(nil, nil), nil)
93 require.Error(t, err)
94}
95
96func TestListenUnixRejectsExistingPathWithoutRemovingIt(t *testing.T) {
97 path := filepath.Join(shortDir(t), "ui.sock")
98 require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
99 _, err := ListenUnix(path, New(nil, nil), nil)
100 require.Error(t, err)
101 data, readErr := os.ReadFile(path)
102 require.NoError(t, readErr)
103 require.Equal(t, []byte("keep"), data)
104}
105
106func TestUnixEndpointClosePreservesReplacementPath(t *testing.T) {
107 directory := shortDir(t)
108 path := filepath.Join(directory, "ui.sock")
109 endpoint, err := ListenUnix(path, New(nil, nil), nil)
110 require.NoError(t, err)
111 defer func() { _ = endpoint.Close() }()
112
113 replacement := filepath.Join(directory, "replacement")
114 require.NoError(t, os.Rename(path, replacement))
115 require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
116 require.NoError(t, endpoint.Close())
117
118 data, err := os.ReadFile(path)
119 require.NoError(t, err)
120 require.Equal(t, []byte("keep"), data)
121 require.NoError(t, os.Remove(replacement))
122}
123
124func TestServeUsesAttachmentWideActionLimitAcrossConnections(t *testing.T) {
125 service := portsmocks.NewMockUIService(t)
126 started := make(chan struct{})
127 release := make(chan struct{})
128 service.EXPECT().Action(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIActionRequest) (ports.UIActionResult, error) {
129 close(started)
130 select {
131 case <-release:
132 return ports.UIActionResult{ActionID: 1, Accepted: true, Status: ports.UIActionProcessed}, nil
133 case <-ctx.Done():
134 return ports.UIActionResult{}, ctx.Err()
135 }
136 }).Once()
137 server := New(service, nil)
138 firstHost, firstPeer := net.Pipe()
139 secondHost, secondPeer := net.Pipe()
140 defer func() { _ = firstPeer.Close() }()
141 defer func() { _ = secondPeer.Close() }()
142 firstDone := make(chan error, 1)
143 secondDone := make(chan error, 1)
144 go func() { firstDone <- server.Serve(context.Background(), firstHost, testReady(true)) }()
145 go func() { secondDone <- server.Serve(context.Background(), secondHost, testReady(true)) }()
146 firstDecoder := json.NewDecoder(firstPeer)
147 secondDecoder := json.NewDecoder(secondPeer)
148 require.Zero(t, readEnvelope(t, firstDecoder).ID)
149 require.Zero(t, readEnvelope(t, secondDecoder).ID)
150 request := `{"version":1,"id":1,"op":"text","attachment":"a","generation":1,"text":"x"}` + "\n"
151 _, err := firstPeer.Write([]byte(request))
152 require.NoError(t, err)
153 select {
154 case <-started:
155 case <-time.After(time.Second):
156 t.Fatal("first action did not start")
157 }
158 _, err = secondPeer.Write([]byte(request))
159 require.NoError(t, err)
160 busy := readEnvelope(t, secondDecoder)
161 require.Equal(t, ports.UIErrBusy, busy.Error.Code)
162 close(release)
163 processed := readEnvelope(t, firstDecoder)
164 require.Nil(t, processed.Error)
165 require.NoError(t, firstPeer.Close())
166 require.NoError(t, secondPeer.Close())
167 select {
168 case <-firstDone:
169 case <-time.After(time.Second):
170 t.Fatal("first socket server did not stop")
171 }
172 select {
173 case <-secondDone:
174 case <-time.After(time.Second):
175 t.Fatal("second socket server did not stop")
176 }
177}
178
179func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) {
180 directory := shortDir(t)
181 path := filepath.Join(directory, "ui.sock")
182 endpoint, err := ListenUnix(path, New(nil, nil), func() Ready { return testReady(false) })
183 require.NoError(t, err)
184 defer func() { _ = endpoint.Close() }()
185
186 var output bytes.Buffer
187 require.NoError(t, Bridge(context.Background(), path, strings.NewReader(""), &output))
188 decoder := json.NewDecoder(&output)
189 var ready envelope
190 require.NoError(t, decoder.Decode(&ready))
191 require.Zero(t, ready.ID)
192}
193
194// shortDir returns a private directory whose absolute path stays inside the
195// unix socket length limit. It is created under the system temporary
196// directory: the checkout's own path can already be long enough to overflow
197// the limit in a nested worktree.
198func shortDir(t *testing.T) string {
199 t.Helper()
200 base, err := filepath.EvalSymlinks(os.TempDir())
201 require.NoError(t, err)
202 directory, err := os.MkdirTemp(base, "vev-ui")
203 require.NoError(t, err)
204 directory, err = filepath.Abs(directory)
205 require.NoError(t, err)
206 require.NoError(t, safedir.EnsurePrivate(directory))
207 t.Cleanup(func() { require.NoError(t, os.RemoveAll(directory)) })
208 return directory
209}
210
211func fileMode(t *testing.T, path string) os.FileMode {
212 t.Helper()
213 info, err := os.Stat(path)
214 require.NoError(t, err)
215 return info.Mode().Perm()
216}