v0.8.1 1f54f61

socket.go RawHistoryFolder

1package uidriver
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io"
8	"net"
9	"os"
10	"path/filepath"
11	"sync"
12	"time"
13
14	"github.com/bnema/vev/pkg/safedir"
15)
16
17const (
18	unixSocketMode   = 0o600
19	acceptRetryDelay = 10 * time.Millisecond
20)
21
22// UnixEndpoint exposes one already-bound attachment. It never creates or
23// owns a Runner; closing it only closes observer connections and its socket.
24type UnixEndpoint struct {
25	path   string
26	ln     *net.UnixListener
27	server *Server
28	ready  func() Ready
29
30	ctx    context.Context
31	cancel context.CancelFunc
32	done   chan struct{}
33	wg     sync.WaitGroup
34	once   sync.Once
35	file   os.FileInfo
36}
37
38// ListenUnix binds a private, per-client UI socket. Existing paths are always
39// rejected, including stale sockets, so cleanup can never remove a caller's
40// file.
41func ListenUnix(path string, server *Server, ready func() Ready) (*UnixEndpoint, error) {
42	if server == nil {
43		return nil, errors.New("uidriver: nil server")
44	}
45	if err := validateSocketPath(path); err != nil {
46		return nil, err
47	}
48	parent := filepath.Dir(path)
49	if err := secureSocketParent(parent); err != nil {
50		return nil, fmt.Errorf("uidriver: secure socket directory: %w", err)
51	}
52	if _, err := os.Lstat(path); err == nil {
53		return nil, errors.New("uidriver: socket path already exists")
54	} else if !errors.Is(err, os.ErrNotExist) {
55		return nil, fmt.Errorf("uidriver: inspect socket path: %w", err)
56	}
57	address := &net.UnixAddr{Name: path, Net: "unix"}
58	listener, err := net.ListenUnix("unix", address)
59	if err != nil {
60		return nil, fmt.Errorf("uidriver: listen on socket: %w", err)
61	}
62	// Never let net.UnixListener unlink the pathname behind our ownership
63	// check. A caller may replace the path after bind; cleanup must preserve
64	// that replacement rather than deleting it during Close.
65	listener.SetUnlinkOnClose(false)
66	boundInfo, infoErr := os.Lstat(path)
67	cleanup := func(cleanErr error) (*UnixEndpoint, error) {
68		_ = listener.Close()
69		if removeErr := removeOwnedSocket(path, boundInfo); removeErr != nil {
70			cleanErr = errors.Join(cleanErr, removeErr)
71		}
72		return nil, cleanErr
73	}
74	if infoErr != nil {
75		return cleanup(fmt.Errorf("uidriver: stat socket: %w", infoErr))
76	}
77	if err := os.Chmod(path, unixSocketMode); err != nil {
78		return cleanup(fmt.Errorf("uidriver: secure socket: %w", err))
79	}
80	info, err := os.Lstat(path)
81	if err != nil {
82		return cleanup(fmt.Errorf("uidriver: stat socket: %w", err))
83	}
84	ctx, cancel := context.WithCancel(context.Background())
85	endpoint := &UnixEndpoint{path: path, ln: listener, server: server, ready: ready, ctx: ctx, cancel: cancel, done: make(chan struct{}), file: info}
86	endpoint.wg.Add(1)
87	go endpoint.accept()
88	return endpoint, nil
89}
90
91func secureSocketParent(parent string) error {
92	for current := parent; ; current = filepath.Dir(current) {
93		info, err := os.Lstat(current)
94		if err == nil {
95			if info.Mode()&os.ModeSymlink != 0 {
96				return fmt.Errorf("safedir: %s is a symlink", current)
97			}
98			if !info.IsDir() {
99				return fmt.Errorf("safedir: %s is not a directory", current)
100			}
101			if current == filepath.Dir(current) {
102				break
103			}
104			continue
105		}
106		if !errors.Is(err, os.ErrNotExist) {
107			return err
108		}
109		next := filepath.Dir(current)
110		if next == current {
111			break
112		}
113	}
114	return safedir.EnsurePrivate(parent)
115}
116
117func validateSocketPath(path string) error {
118	if path == "" || len(path) > unixSocketMax || !filepath.IsAbs(path) {
119		return errors.New("uidriver: socket path must be an absolute path within Unix socket limits")
120	}
121	if filepath.Clean(path) != path || path == string(filepath.Separator) || filepath.Base(path) == "." || filepath.Base(path) == ".." {
122		return errors.New("uidriver: invalid socket path")
123	}
124	for i := 0; i < len(path); i++ {
125		if path[i] == 0 {
126			return errors.New("uidriver: socket path contains a NUL")
127		}
128	}
129	return nil
130}
131
132// DefaultSocketPath chooses a short private path under the current vev runtime
133// directory. The handle is already opaque and contains only hex characters.
134func DefaultSocketPath(runtimeDir, handle string) string {
135	return filepath.Join(runtimeDir, "ui", handle+".sock")
136}
137
138func (e *UnixEndpoint) accept() {
139	defer close(e.done)
140	defer e.wg.Done()
141	for {
142		conn, err := e.ln.AcceptUnix()
143		if err != nil {
144			if e.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
145				return
146			}
147			select {
148			case <-e.ctx.Done():
149				return
150			case <-time.After(acceptRetryDelay):
151			}
152			continue
153		}
154		if !peerIsCurrentUser(conn) {
155			_ = conn.Close()
156			continue
157		}
158		e.wg.Add(1)
159		go func() {
160			defer e.wg.Done()
161			ready := Ready{}
162			if e.ready != nil {
163				ready = e.ready()
164			}
165			_ = e.server.Serve(e.ctx, conn, ready)
166		}()
167	}
168}
169
170// Path is the bound filesystem path.
171func (e *UnixEndpoint) Path() string { return e.path }
172
173func removeOwnedSocket(path string, expected os.FileInfo) error {
174	if expected == nil {
175		return nil
176	}
177	info, err := os.Lstat(path)
178	if err != nil {
179		if errors.Is(err, os.ErrNotExist) {
180			return nil
181		}
182		return err
183	}
184	if !os.SameFile(info, expected) {
185		return nil
186	}
187	if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
188		return err
189	}
190	return nil
191}
192
193// Close stops accepting connections and removes only the socket inode created
194// by this endpoint.
195func (e *UnixEndpoint) Close() error {
196	var closeErr error
197	e.once.Do(func() {
198		e.cancel()
199		if err := e.ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
200			closeErr = err
201		}
202		e.wg.Wait()
203		if err := removeOwnedSocket(e.path, e.file); err != nil && closeErr == nil {
204			closeErr = err
205		}
206	})
207	return closeErr
208}
209
210// Bridge connects stdio to an opted-in interactive UI socket without creating
211// another attachment.
212func Bridge(ctx context.Context, path string, input io.Reader, output io.Writer) error {
213	if err := validateSocketPath(path); err != nil {
214		return err
215	}
216	conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
217	if err != nil {
218		return fmt.Errorf("uidriver: connect socket: %w", err)
219	}
220	defer func() { _ = conn.Close() }()
221	ctx, cancel := context.WithCancel(ctx)
222	defer cancel()
223	inputDone := make(chan error, 1)
224	outputDone := make(chan error, 1)
225	go func() {
226		_, copyErr := io.Copy(conn, input)
227		inputDone <- copyErr
228	}()
229	go func() {
230		_, copyErr := io.Copy(output, conn)
231		outputDone <- copyErr
232	}()
233	select {
234	case inputErr := <-inputDone:
235		if inputErr != nil && !errors.Is(inputErr, io.EOF) {
236			_ = conn.Close()
237			return inputErr
238		}
239		if unixConn, ok := conn.(*net.UnixConn); ok {
240			_ = unixConn.CloseWrite()
241		} else {
242			_ = conn.Close()
243		}
244		select {
245		case outputErr := <-outputDone:
246			return bridgeCopyError(outputErr)
247		case <-ctx.Done():
248			return ctx.Err()
249		}
250	case err := <-outputDone:
251		_ = conn.Close()
252		return bridgeCopyError(err)
253	case <-ctx.Done():
254		_ = conn.Close()
255		return ctx.Err()
256	}
257}
258
259func bridgeCopyError(err error) error {
260	if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
261		return nil
262	}
263	return err
264}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close