v0.2.0 80c61e3

host_registry.go RawHistoryFolder

1package remote
2
3import (
4	"encoding/json"
5	"errors"
6	"fmt"
7	"log/slog"
8	"os"
9	"path/filepath"
10	"slices"
11	"sort"
12	"syscall"
13	"unicode/utf8"
14
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/ports"
17	"github.com/bnema/vev/pkg/safedir"
18)
19
20const (
21	hostsFileName    = "hosts.json"
22	hostsFileVersion = 2
23)
24
25// HostStorePath returns the canonical location of the remote host store in stateDir.
26func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
27
28type hostsFile struct {
29	Version int      `json:"version"`
30	Pinned  []string `json:"pinned"`
31	Learned []string `json:"learned"`
32}
33
34type legacyHostsFile struct {
35	Version int      `json:"version"`
36	Hosts   []string `json:"hosts"`
37}
38
39type hostState struct {
40	pinned  []string
41	learned map[string]struct{}
42}
43
44type fileHostStore struct {
45	path string
46}
47
48// NewFileHostStore returns a unified pinned/learned host store backed by path.
49func NewFileHostStore(path string) ports.RemoteHostStore {
50	return &fileHostStore{path: path}
51}
52
53var _ ports.RemoteHostStore = (*fileHostStore)(nil)
54
55func (s *fileHostStore) Hosts() (pinned, learned []string, err error) {
56	err = s.withLock(func() error {
57		state, loadErr := s.loadLocked()
58		if loadErr != nil {
59			return loadErr
60		}
61		pinned = append([]string(nil), state.pinned...)
62		learned = sortedHosts(state.learned)
63		return nil
64	})
65	if err != nil {
66		slog.Debug("remote host store hosts failed", "path", s.path, "err", err)
67		return nil, nil, err
68	}
69	return pinned, learned, nil
70}
71
72func (s *fileHostStore) AddPinned(target string) error {
73	if err := validateHostTarget(target); err != nil {
74		return err
75	}
76	err := s.withLock(func() error {
77		state, err := s.loadLocked()
78		if err != nil {
79			return err
80		}
81		if slices.Contains(state.pinned, target) {
82			return nil
83		}
84		state.pinned = append(state.pinned, target)
85		return s.saveLocked(state)
86	})
87	if err != nil {
88		slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err)
89	}
90	return err
91}
92
93func (s *fileHostStore) RemovePinned(target string) error {
94	if err := validateHostTarget(target); err != nil {
95		return err
96	}
97	err := s.withLock(func() error {
98		state, err := s.loadLocked()
99		if err != nil {
100			return err
101		}
102		next, changed := removeTarget(state.pinned, target)
103		if !changed {
104			return nil
105		}
106		state.pinned = next
107		return s.saveLocked(state)
108	})
109	if err != nil {
110		slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err)
111	}
112	return err
113}
114
115func (s *fileHostStore) Remember(target string) error {
116	if err := validateHostTarget(target); err != nil {
117		return err
118	}
119	err := s.withLock(func() error {
120		state, err := s.loadLocked()
121		if err != nil {
122			return err
123		}
124		if _, ok := state.learned[target]; ok {
125			return nil
126		}
127		state.learned[target] = struct{}{}
128		return s.saveLocked(state)
129	})
130	if err != nil {
131		slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err)
132	}
133	return err
134}
135
136func (s *fileHostStore) Forget(target string) error {
137	if err := validateHostTarget(target); err != nil {
138		return err
139	}
140	err := s.withLock(func() error {
141		state, err := s.loadLocked()
142		if err != nil {
143			return err
144		}
145		if _, ok := state.learned[target]; !ok {
146			return nil
147		}
148		delete(state.learned, target)
149		return s.saveLocked(state)
150	})
151	if err != nil {
152		slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err)
153	}
154	return err
155}
156
157func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
158	if err := validateHostTarget(target); err != nil {
159		return false, err
160	}
161	err = s.withLock(func() error {
162		state, err := s.loadLocked()
163		if err != nil {
164			return err
165		}
166		nextPinned, pinnedChanged := removeTarget(state.pinned, target)
167		_, learnedPresent := state.learned[target]
168		if !pinnedChanged && !learnedPresent {
169			return nil
170		}
171		state.pinned = nextPinned
172		delete(state.learned, target)
173		if err := s.saveLocked(state); err != nil {
174			return err
175		}
176		deleted = true
177		return nil
178	})
179	if err != nil {
180		slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
181		return false, err
182	}
183	return deleted, nil
184}
185
186func (s *fileHostStore) withLock(fn func() error) error {
187	if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil {
188		return err
189	}
190	lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
191	if err != nil {
192		return err
193	}
194	defer func() { _ = lock.Close() }()
195	if err := lock.Chmod(0o600); err != nil {
196		return err
197	}
198	if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
199		return err
200	}
201	defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
202	return fn()
203}
204
205func (s *fileHostStore) loadLocked() (hostState, error) {
206	raw, err := os.ReadFile(s.path)
207	if errors.Is(err, os.ErrNotExist) {
208		return hostState{
209			pinned:  make([]string, 0),
210			learned: make(map[string]struct{}),
211		}, nil
212	}
213	if err != nil {
214		return hostState{}, err
215	}
216	if !utf8.Valid(raw) {
217		return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8")
218	}
219	var header struct {
220		Version int `json:"version"`
221	}
222	if err := json.Unmarshal(raw, &header); err != nil {
223		return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
224	}
225
226	switch header.Version {
227	case 1:
228		var legacy legacyHostsFile
229		if err := json.Unmarshal(raw, &legacy); err != nil {
230			return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
231		}
232		if legacy.Hosts == nil {
233			return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
234		}
235		learned, err := validateLearnedHosts(legacy.Hosts)
236		if err != nil {
237			return hostState{}, err
238		}
239		return hostState{pinned: []string{}, learned: learned}, nil
240	case hostsFileVersion:
241		var decoded hostsFile
242		if err := json.Unmarshal(raw, &decoded); err != nil {
243			return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
244		}
245		if decoded.Pinned == nil {
246			return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
247		}
248		if decoded.Learned == nil {
249			return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
250		}
251		pinned, err := validatePinnedHosts(decoded.Pinned)
252		if err != nil {
253			return hostState{}, err
254		}
255		learned, err := validateLearnedHosts(decoded.Learned)
256		if err != nil {
257			return hostState{}, err
258		}
259		return hostState{pinned: pinned, learned: learned}, nil
260	default:
261		return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
262	}
263}
264
265func (s *fileHostStore) saveLocked(state hostState) error {
266	pinned := append([]string{}, state.pinned...)
267	if pinned == nil {
268		pinned = []string{}
269	}
270	learned := sortedHosts(state.learned)
271	if learned == nil {
272		learned = []string{}
273	}
274	payload, err := json.Marshal(hostsFile{
275		Version: hostsFileVersion,
276		Pinned:  pinned,
277		Learned: learned,
278	})
279	if err != nil {
280		return err
281	}
282	return atomicReplacePrivateFile(s.path, append(payload, '\n'))
283}
284
285func atomicReplacePrivateFile(path string, payload []byte) error {
286	dir := filepath.Dir(path)
287	if err := safedir.EnsurePrivate(dir); err != nil {
288		return err
289	}
290	tmp, err := os.CreateTemp(dir, ".vev-*.tmp")
291	if err != nil {
292		return err
293	}
294	tmpName := tmp.Name()
295	committed := false
296	defer func() {
297		if !committed {
298			_ = tmp.Close()
299			_ = os.Remove(tmpName)
300		}
301	}()
302	if err := tmp.Chmod(0o600); err != nil {
303		return err
304	}
305	if _, err := tmp.Write(payload); err != nil {
306		return err
307	}
308	if err := tmp.Sync(); err != nil {
309		return err
310	}
311	if err := tmp.Close(); err != nil {
312		return err
313	}
314	if err := os.Rename(tmpName, path); err != nil {
315		return err
316	}
317	committed = true
318	return syncDir(dir)
319}
320
321func validatePinnedHosts(hosts []string) ([]string, error) {
322	pinned := make([]string, 0, len(hosts))
323	seen := make(map[string]struct{}, len(hosts))
324	for _, host := range hosts {
325		if err := validateHostTarget(host); err != nil {
326			return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
327		}
328		if _, dup := seen[host]; dup {
329			return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
330		}
331		seen[host] = struct{}{}
332		pinned = append(pinned, host)
333	}
334	return pinned, nil
335}
336
337func validateLearnedHosts(hosts []string) (map[string]struct{}, error) {
338	learned := make(map[string]struct{}, len(hosts))
339	for _, host := range hosts {
340		if err := validateHostTarget(host); err != nil {
341			return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
342		}
343		if _, dup := learned[host]; dup {
344			return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
345		}
346		learned[host] = struct{}{}
347	}
348	return learned, nil
349}
350
351func validateHostTarget(target string) error {
352	if !utf8.ValidString(target) {
353		return fmt.Errorf("remote host target is not valid UTF-8")
354	}
355	return domain.ValidateRemoteHostTarget(target)
356}
357
358func removeTarget(hosts []string, target string) ([]string, bool) {
359	out := make([]string, 0, len(hosts))
360	changed := false
361	for _, host := range hosts {
362		if host == target {
363			changed = true
364			continue
365		}
366		out = append(out, host)
367	}
368	return out, changed
369}
370
371func sortedHosts(hosts map[string]struct{}) []string {
372	out := make([]string, 0, len(hosts))
373	for host := range hosts {
374		out = append(out, host)
375	}
376	sort.Strings(out)
377	return out
378}
379
380func syncDir(dir string) error {
381	file, err := os.Open(dir)
382	if err != nil {
383		return err
384	}
385	return errors.Join(file.Sync(), file.Close())
386}

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