detached d94ca5a

feat(remote): add unified host state store

Signed-off-by: brice <b@bnema.dev>
commit
d94ca5ad778d26a31235aa5920f4f9c0c9280834
author
brice · · 2026-07-30 09:14 UTC
parent
f8408e0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1375 -0
A internal/adapters/remote/host_registry.go +370 -0
@@ -0,0 +1,370 @@
1+package remote
2+
3+import (
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+
20+const hostsFileVersion = 2
21+
22+type hostsFile struct {
23+ Version int `json:"version"`
24+ Pinned []string `json:"pinned"`
25+ Learned []string `json:"learned"`
26+}
27+
28+type legacyHostsFile struct {
29+ Version int `json:"version"`
30+ Hosts []string `json:"hosts"`
31+}
32+
33+type hostState struct {
34+ pinned []string
35+ learned map[string]struct{}
36+}
37+
38+type fileHostStore struct {
39+ path string
40+}
41+
42+// NewFileHostStore returns a unified pinned/learned host store backed by path.
43+func NewFileHostStore(path string) ports.RemoteHostStore {
44+ return &fileHostStore{path: path}
45+}
46+
47+var _ ports.RemoteHostStore = (*fileHostStore)(nil)
48+
49+func (s *fileHostStore) Hosts() (pinned, learned []string, err error) {
50+ err = s.withLock(func() error {
51+ state, loadErr := s.loadLocked()
52+ if loadErr != nil {
53+ return loadErr
54+ }
55+ pinned = append([]string(nil), state.pinned...)
56+ learned = sortedHosts(state.learned)
57+ return nil
58+ })
59+ if err != nil {
60+ slog.Debug("remote host store hosts failed", "path", s.path, "err", err)
61+ return nil, nil, err
62+ }
63+ return pinned, learned, nil
64+}
65+
66+func (s *fileHostStore) AddPinned(target string) error {
67+ if err := validateHostTarget(target); err != nil {
68+ return err
69+ }
70+ err := s.withLock(func() error {
71+ state, err := s.loadLocked()
72+ if err != nil {
73+ return err
74+ }
75+ if slices.Contains(state.pinned, target) {
76+ return nil
77+ }
78+ state.pinned = append(state.pinned, target)
79+ return s.saveLocked(state)
80+ })
81+ if err != nil {
82+ slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err)
83+ }
84+ return err
85+}
86+
87+func (s *fileHostStore) RemovePinned(target string) error {
88+ if err := validateHostTarget(target); err != nil {
89+ return err
90+ }
91+ err := s.withLock(func() error {
92+ state, err := s.loadLocked()
93+ if err != nil {
94+ return err
95+ }
96+ next, changed := removeTarget(state.pinned, target)
97+ if !changed {
98+ return nil
99+ }
100+ state.pinned = next
101+ return s.saveLocked(state)
102+ })
103+ if err != nil {
104+ slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err)
105+ }
106+ return err
107+}
108+
109+func (s *fileHostStore) Remember(target string) error {
110+ if err := validateHostTarget(target); err != nil {
111+ return err
112+ }
113+ err := s.withLock(func() error {
114+ state, err := s.loadLocked()
115+ if err != nil {
116+ return err
117+ }
118+ if _, ok := state.learned[target]; ok {
119+ return nil
120+ }
121+ state.learned[target] = struct{}{}
122+ return s.saveLocked(state)
123+ })
124+ if err != nil {
125+ slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err)
126+ }
127+ return err
128+}
129+
130+func (s *fileHostStore) Forget(target string) error {
131+ if err := validateHostTarget(target); err != nil {
132+ return err
133+ }
134+ err := s.withLock(func() error {
135+ state, err := s.loadLocked()
136+ if err != nil {
137+ return err
138+ }
139+ if _, ok := state.learned[target]; !ok {
140+ return nil
141+ }
142+ delete(state.learned, target)
143+ return s.saveLocked(state)
144+ })
145+ if err != nil {
146+ slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err)
147+ }
148+ return err
149+}
150+
151+func (s *fileHostStore) Remove(target string) error {
152+ if err := validateHostTarget(target); err != nil {
153+ return err
154+ }
155+ err := s.withLock(func() error {
156+ state, err := s.loadLocked()
157+ if err != nil {
158+ return err
159+ }
160+ nextPinned, pinnedChanged := removeTarget(state.pinned, target)
161+ _, learnedPresent := state.learned[target]
162+ if !pinnedChanged && !learnedPresent {
163+ return nil
164+ }
165+ state.pinned = nextPinned
166+ delete(state.learned, target)
167+ return s.saveLocked(state)
168+ })
169+ if err != nil {
170+ slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
171+ }
172+ return err
173+}
174+
175+func (s *fileHostStore) withLock(fn func() error) error {
176+ if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil {
177+ return err
178+ }
179+ lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
180+ if err != nil {
181+ return err
182+ }
183+ defer func() { _ = lock.Close() }()
184+ if err := lock.Chmod(0o600); err != nil {
185+ return err
186+ }
187+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
188+ return err
189+ }
190+ defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
191+ return fn()
192+}
193+
194+func (s *fileHostStore) loadLocked() (hostState, error) {
195+ raw, err := os.ReadFile(s.path)
196+ if errors.Is(err, os.ErrNotExist) {
197+ return hostState{
198+ pinned: make([]string, 0),
199+ learned: make(map[string]struct{}),
200+ }, nil
201+ }
202+ if err != nil {
203+ return hostState{}, err
204+ }
205+ if !utf8.Valid(raw) {
206+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8")
207+ }
208+ var header struct {
209+ Version int `json:"version"`
210+ }
211+ if err := json.Unmarshal(raw, &header); err != nil {
212+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
213+ }
214+
215+ switch header.Version {
216+ case 1:
217+ var legacy legacyHostsFile
218+ if err := json.Unmarshal(raw, &legacy); err != nil {
219+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
220+ }
221+ if legacy.Hosts == nil {
222+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
223+ }
224+ learned, err := validateLearnedHosts(legacy.Hosts)
225+ if err != nil {
226+ return hostState{}, err
227+ }
228+ return hostState{pinned: []string{}, learned: learned}, nil
229+ case hostsFileVersion:
230+ var decoded hostsFile
231+ if err := json.Unmarshal(raw, &decoded); err != nil {
232+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
233+ }
234+ if decoded.Pinned == nil {
235+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
236+ }
237+ if decoded.Learned == nil {
238+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
239+ }
240+ pinned, err := validatePinnedHosts(decoded.Pinned)
241+ if err != nil {
242+ return hostState{}, err
243+ }
244+ learned, err := validateLearnedHosts(decoded.Learned)
245+ if err != nil {
246+ return hostState{}, err
247+ }
248+ return hostState{pinned: pinned, learned: learned}, nil
249+ default:
250+ return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
251+ }
252+}
253+
254+func (s *fileHostStore) saveLocked(state hostState) error {
255+ pinned := append([]string{}, state.pinned...)
256+ if pinned == nil {
257+ pinned = []string{}
258+ }
259+ learned := sortedHosts(state.learned)
260+ if learned == nil {
261+ learned = []string{}
262+ }
263+ payload, err := json.Marshal(hostsFile{
264+ Version: hostsFileVersion,
265+ Pinned: pinned,
266+ Learned: learned,
267+ })
268+ if err != nil {
269+ return err
270+ }
271+ payload = append(payload, '\n')
272+
273+ dir := filepath.Dir(s.path)
274+ tmp, err := os.CreateTemp(dir, ".hosts-*.tmp")
275+ if err != nil {
276+ return err
277+ }
278+ tmpName := tmp.Name()
279+ committed := false
280+ defer func() {
281+ if !committed {
282+ _ = tmp.Close()
283+ _ = os.Remove(tmpName)
284+ }
285+ }()
286+ if err := tmp.Chmod(0o600); err != nil {
287+ return err
288+ }
289+ if _, err := tmp.Write(payload); err != nil {
290+ return err
291+ }
292+ if err := tmp.Sync(); err != nil {
293+ return err
294+ }
295+ if err := tmp.Close(); err != nil {
296+ return err
297+ }
298+ if err := os.Rename(tmpName, s.path); err != nil {
299+ return err
300+ }
301+ committed = true
302+ return syncDir(dir)
303+}
304+
305+func validatePinnedHosts(hosts []string) ([]string, error) {
306+ pinned := make([]string, 0, len(hosts))
307+ seen := make(map[string]struct{}, len(hosts))
308+ for _, host := range hosts {
309+ if err := validateHostTarget(host); err != nil {
310+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
311+ }
312+ if _, dup := seen[host]; dup {
313+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
314+ }
315+ seen[host] = struct{}{}
316+ pinned = append(pinned, host)
317+ }
318+ return pinned, nil
319+}
320+
321+func validateLearnedHosts(hosts []string) (map[string]struct{}, error) {
322+ learned := make(map[string]struct{}, len(hosts))
323+ for _, host := range hosts {
324+ if err := validateHostTarget(host); err != nil {
325+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
326+ }
327+ if _, dup := learned[host]; dup {
328+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
329+ }
330+ learned[host] = struct{}{}
331+ }
332+ return learned, nil
333+}
334+
335+func validateHostTarget(target string) error {
336+ if !utf8.ValidString(target) {
337+ return fmt.Errorf("remote host target is not valid UTF-8")
338+ }
339+ return domain.ValidateRemoteHostTarget(target)
340+}
341+
342+func removeTarget(hosts []string, target string) ([]string, bool) {
343+ out := make([]string, 0, len(hosts))
344+ changed := false
345+ for _, host := range hosts {
346+ if host == target {
347+ changed = true
348+ continue
349+ }
350+ out = append(out, host)
351+ }
352+ return out, changed
353+}
354+
355+func sortedHosts(hosts map[string]struct{}) []string {
356+ out := make([]string, 0, len(hosts))
357+ for host := range hosts {
358+ out = append(out, host)
359+ }
360+ sort.Strings(out)
361+ return out
362+}
363+
364+func syncDir(dir string) error {
365+ file, err := os.Open(dir)
366+ if err != nil {
367+ return err
368+ }
369+ return errors.Join(file.Sync(), file.Close())
370+}
@@ -0,0 +1,370 @@
1+package remote
2+
3+import (
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+
20+const hostsFileVersion = 2
21+
22+type hostsFile struct {
23+ Version int `json:"version"`
24+ Pinned []string `json:"pinned"`
25+ Learned []string `json:"learned"`
26+}
27+
28+type legacyHostsFile struct {
29+ Version int `json:"version"`
30+ Hosts []string `json:"hosts"`
31+}
32+
33+type hostState struct {
34+ pinned []string
35+ learned map[string]struct{}
36+}
37+
38+type fileHostStore struct {
39+ path string
40+}
41+
42+// NewFileHostStore returns a unified pinned/learned host store backed by path.
43+func NewFileHostStore(path string) ports.RemoteHostStore {
44+ return &fileHostStore{path: path}
45+}
46+
47+var _ ports.RemoteHostStore = (*fileHostStore)(nil)
48+
49+func (s *fileHostStore) Hosts() (pinned, learned []string, err error) {
50+ err = s.withLock(func() error {
51+ state, loadErr := s.loadLocked()
52+ if loadErr != nil {
53+ return loadErr
54+ }
55+ pinned = append([]string(nil), state.pinned...)
56+ learned = sortedHosts(state.learned)
57+ return nil
58+ })
59+ if err != nil {
60+ slog.Debug("remote host store hosts failed", "path", s.path, "err", err)
61+ return nil, nil, err
62+ }
63+ return pinned, learned, nil
64+}
65+
66+func (s *fileHostStore) AddPinned(target string) error {
67+ if err := validateHostTarget(target); err != nil {
68+ return err
69+ }
70+ err := s.withLock(func() error {
71+ state, err := s.loadLocked()
72+ if err != nil {
73+ return err
74+ }
75+ if slices.Contains(state.pinned, target) {
76+ return nil
77+ }
78+ state.pinned = append(state.pinned, target)
79+ return s.saveLocked(state)
80+ })
81+ if err != nil {
82+ slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err)
83+ }
84+ return err
85+}
86+
87+func (s *fileHostStore) RemovePinned(target string) error {
88+ if err := validateHostTarget(target); err != nil {
89+ return err
90+ }
91+ err := s.withLock(func() error {
92+ state, err := s.loadLocked()
93+ if err != nil {
94+ return err
95+ }
96+ next, changed := removeTarget(state.pinned, target)
97+ if !changed {
98+ return nil
99+ }
100+ state.pinned = next
101+ return s.saveLocked(state)
102+ })
103+ if err != nil {
104+ slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err)
105+ }
106+ return err
107+}
108+
109+func (s *fileHostStore) Remember(target string) error {
110+ if err := validateHostTarget(target); err != nil {
111+ return err
112+ }
113+ err := s.withLock(func() error {
114+ state, err := s.loadLocked()
115+ if err != nil {
116+ return err
117+ }
118+ if _, ok := state.learned[target]; ok {
119+ return nil
120+ }
121+ state.learned[target] = struct{}{}
122+ return s.saveLocked(state)
123+ })
124+ if err != nil {
125+ slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err)
126+ }
127+ return err
128+}
129+
130+func (s *fileHostStore) Forget(target string) error {
131+ if err := validateHostTarget(target); err != nil {
132+ return err
133+ }
134+ err := s.withLock(func() error {
135+ state, err := s.loadLocked()
136+ if err != nil {
137+ return err
138+ }
139+ if _, ok := state.learned[target]; !ok {
140+ return nil
141+ }
142+ delete(state.learned, target)
143+ return s.saveLocked(state)
144+ })
145+ if err != nil {
146+ slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err)
147+ }
148+ return err
149+}
150+
151+func (s *fileHostStore) Remove(target string) error {
152+ if err := validateHostTarget(target); err != nil {
153+ return err
154+ }
155+ err := s.withLock(func() error {
156+ state, err := s.loadLocked()
157+ if err != nil {
158+ return err
159+ }
160+ nextPinned, pinnedChanged := removeTarget(state.pinned, target)
161+ _, learnedPresent := state.learned[target]
162+ if !pinnedChanged && !learnedPresent {
163+ return nil
164+ }
165+ state.pinned = nextPinned
166+ delete(state.learned, target)
167+ return s.saveLocked(state)
168+ })
169+ if err != nil {
170+ slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
171+ }
172+ return err
173+}
174+
175+func (s *fileHostStore) withLock(fn func() error) error {
176+ if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil {
177+ return err
178+ }
179+ lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
180+ if err != nil {
181+ return err
182+ }
183+ defer func() { _ = lock.Close() }()
184+ if err := lock.Chmod(0o600); err != nil {
185+ return err
186+ }
187+ if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
188+ return err
189+ }
190+ defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
191+ return fn()
192+}
193+
194+func (s *fileHostStore) loadLocked() (hostState, error) {
195+ raw, err := os.ReadFile(s.path)
196+ if errors.Is(err, os.ErrNotExist) {
197+ return hostState{
198+ pinned: make([]string, 0),
199+ learned: make(map[string]struct{}),
200+ }, nil
201+ }
202+ if err != nil {
203+ return hostState{}, err
204+ }
205+ if !utf8.Valid(raw) {
206+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8")
207+ }
208+ var header struct {
209+ Version int `json:"version"`
210+ }
211+ if err := json.Unmarshal(raw, &header); err != nil {
212+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
213+ }
214+
215+ switch header.Version {
216+ case 1:
217+ var legacy legacyHostsFile
218+ if err := json.Unmarshal(raw, &legacy); err != nil {
219+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
220+ }
221+ if legacy.Hosts == nil {
222+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
223+ }
224+ learned, err := validateLearnedHosts(legacy.Hosts)
225+ if err != nil {
226+ return hostState{}, err
227+ }
228+ return hostState{pinned: []string{}, learned: learned}, nil
229+ case hostsFileVersion:
230+ var decoded hostsFile
231+ if err := json.Unmarshal(raw, &decoded); err != nil {
232+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
233+ }
234+ if decoded.Pinned == nil {
235+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
236+ }
237+ if decoded.Learned == nil {
238+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
239+ }
240+ pinned, err := validatePinnedHosts(decoded.Pinned)
241+ if err != nil {
242+ return hostState{}, err
243+ }
244+ learned, err := validateLearnedHosts(decoded.Learned)
245+ if err != nil {
246+ return hostState{}, err
247+ }
248+ return hostState{pinned: pinned, learned: learned}, nil
249+ default:
250+ return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
251+ }
252+}
253+
254+func (s *fileHostStore) saveLocked(state hostState) error {
255+ pinned := append([]string{}, state.pinned...)
256+ if pinned == nil {
257+ pinned = []string{}
258+ }
259+ learned := sortedHosts(state.learned)
260+ if learned == nil {
261+ learned = []string{}
262+ }
263+ payload, err := json.Marshal(hostsFile{
264+ Version: hostsFileVersion,
265+ Pinned: pinned,
266+ Learned: learned,
267+ })
268+ if err != nil {
269+ return err
270+ }
271+ payload = append(payload, '\n')
272+
273+ dir := filepath.Dir(s.path)
274+ tmp, err := os.CreateTemp(dir, ".hosts-*.tmp")
275+ if err != nil {
276+ return err
277+ }
278+ tmpName := tmp.Name()
279+ committed := false
280+ defer func() {
281+ if !committed {
282+ _ = tmp.Close()
283+ _ = os.Remove(tmpName)
284+ }
285+ }()
286+ if err := tmp.Chmod(0o600); err != nil {
287+ return err
288+ }
289+ if _, err := tmp.Write(payload); err != nil {
290+ return err
291+ }
292+ if err := tmp.Sync(); err != nil {
293+ return err
294+ }
295+ if err := tmp.Close(); err != nil {
296+ return err
297+ }
298+ if err := os.Rename(tmpName, s.path); err != nil {
299+ return err
300+ }
301+ committed = true
302+ return syncDir(dir)
303+}
304+
305+func validatePinnedHosts(hosts []string) ([]string, error) {
306+ pinned := make([]string, 0, len(hosts))
307+ seen := make(map[string]struct{}, len(hosts))
308+ for _, host := range hosts {
309+ if err := validateHostTarget(host); err != nil {
310+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
311+ }
312+ if _, dup := seen[host]; dup {
313+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
314+ }
315+ seen[host] = struct{}{}
316+ pinned = append(pinned, host)
317+ }
318+ return pinned, nil
319+}
320+
321+func validateLearnedHosts(hosts []string) (map[string]struct{}, error) {
322+ learned := make(map[string]struct{}, len(hosts))
323+ for _, host := range hosts {
324+ if err := validateHostTarget(host); err != nil {
325+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
326+ }
327+ if _, dup := learned[host]; dup {
328+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
329+ }
330+ learned[host] = struct{}{}
331+ }
332+ return learned, nil
333+}
334+
335+func validateHostTarget(target string) error {
336+ if !utf8.ValidString(target) {
337+ return fmt.Errorf("remote host target is not valid UTF-8")
338+ }
339+ return domain.ValidateRemoteHostTarget(target)
340+}
341+
342+func removeTarget(hosts []string, target string) ([]string, bool) {
343+ out := make([]string, 0, len(hosts))
344+ changed := false
345+ for _, host := range hosts {
346+ if host == target {
347+ changed = true
348+ continue
349+ }
350+ out = append(out, host)
351+ }
352+ return out, changed
353+}
354+
355+func sortedHosts(hosts map[string]struct{}) []string {
356+ out := make([]string, 0, len(hosts))
357+ for host := range hosts {
358+ out = append(out, host)
359+ }
360+ sort.Strings(out)
361+ return out
362+}
363+
364+func syncDir(dir string) error {
365+ file, err := os.Open(dir)
366+ if err != nil {
367+ return err
368+ }
369+ return errors.Join(file.Sync(), file.Close())
370+}
A internal/adapters/remote/host_registry_test.go +647 -0
@@ -0,0 +1,647 @@
1+package remote
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "slices"
9+ "sort"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "testing"
14+)
15+
16+func TestHostStoreCrossProcessHelper(t *testing.T) {
17+ if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
18+ t.Skip("helper process")
19+ }
20+
21+ path := os.Getenv("VEV_HOST_STORE_PATH")
22+ hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",")
23+ store := NewFileHostStore(path)
24+ for _, host := range hosts {
25+ if host == "" {
26+ continue
27+ }
28+ if err := store.Remember(host); err != nil {
29+ t.Fatalf("Remember(%q) error = %v", host, err)
30+ }
31+ }
32+}
33+
34+func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
35+ path := filepath.Join(t.TempDir(), "vev", "hosts.json")
36+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
37+ t.Fatalf("mkdir: %v", err)
38+ }
39+
40+ left := make([]string, 0, 40)
41+ right := make([]string, 0, 40)
42+ for i := 0; i < 40; i++ {
43+ left = append(left, "left-"+strconv.Itoa(i))
44+ right = append(right, "right-"+strconv.Itoa(i))
45+ }
46+
47+ startHelper := func(hosts []string) *exec.Cmd {
48+ cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$")
49+ cmd.Env = append(os.Environ(),
50+ "VEV_HOST_STORE_HELPER=1",
51+ "VEV_HOST_STORE_PATH="+path,
52+ "VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","),
53+ )
54+ cmd.Stderr = os.Stderr
55+ return cmd
56+ }
57+
58+ leftCmd := startHelper(left)
59+ rightCmd := startHelper(right)
60+ if err := leftCmd.Start(); err != nil {
61+ t.Fatalf("start left helper: %v", err)
62+ }
63+ if err := rightCmd.Start(); err != nil {
64+ t.Fatalf("start right helper: %v", err)
65+ }
66+ if err := leftCmd.Wait(); err != nil {
67+ t.Fatalf("left helper: %v", err)
68+ }
69+ if err := rightCmd.Wait(); err != nil {
70+ t.Fatalf("right helper: %v", err)
71+ }
72+
73+ _, learned, err := NewFileHostStore(path).Hosts()
74+ if err != nil {
75+ t.Fatalf("Hosts() error = %v", err)
76+ }
77+ want := append(append([]string{}, left...), right...)
78+ sort.Strings(want)
79+ if !slices.Equal(learned, want) {
80+ t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
81+ }
82+}
83+
84+func TestHostStore(t *testing.T) {
85+ t.Parallel()
86+
87+ storePath := func(t *testing.T) string {
88+ t.Helper()
89+ dir := filepath.Join(t.TempDir(), "vev")
90+ return filepath.Join(dir, "hosts.json")
91+ }
92+
93+ writeHosts := func(t *testing.T, path string, raw []byte) {
94+ t.Helper()
95+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
96+ t.Fatalf("mkdir: %v", err)
97+ }
98+ if err := os.WriteFile(path, raw, 0o600); err != nil {
99+ t.Fatalf("write hosts: %v", err)
100+ }
101+ }
102+
103+ t.Run("missing file returns empty lists", func(t *testing.T) {
104+ t.Parallel()
105+ path := storePath(t)
106+ store := NewFileHostStore(path)
107+
108+ pinned, learned, err := store.Hosts()
109+ if err != nil {
110+ t.Fatalf("Hosts() error = %v", err)
111+ }
112+ if len(pinned) != 0 || len(learned) != 0 {
113+ t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned)
114+ }
115+ })
116+
117+ t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) {
118+ t.Parallel()
119+ path := storePath(t)
120+ store := NewFileHostStore(path)
121+
122+ if err := store.AddPinned("zebra"); err != nil {
123+ t.Fatalf("AddPinned(zebra) error = %v", err)
124+ }
125+ if err := store.AddPinned("arch"); err != nil {
126+ t.Fatalf("AddPinned(arch) error = %v", err)
127+ }
128+ if err := store.Remember("mule"); err != nil {
129+ t.Fatalf("Remember(mule) error = %v", err)
130+ }
131+ if err := store.Remember("beta"); err != nil {
132+ t.Fatalf("Remember(beta) error = %v", err)
133+ }
134+
135+ pinned, learned, err := store.Hosts()
136+ if err != nil {
137+ t.Fatalf("Hosts() error = %v", err)
138+ }
139+ wantPinned := []string{"zebra", "arch"}
140+ wantLearned := []string{"beta", "mule"}
141+ if !slices.Equal(pinned, wantPinned) {
142+ t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
143+ }
144+ if !slices.Equal(learned, wantLearned) {
145+ t.Fatalf("learned = %v, want %v", learned, wantLearned)
146+ }
147+
148+ info, err := os.Stat(path)
149+ if err != nil {
150+ t.Fatalf("stat store: %v", err)
151+ }
152+ if perm := info.Mode().Perm(); perm != 0o600 {
153+ t.Fatalf("store permissions = %04o, want 0600", perm)
154+ }
155+ dirInfo, err := os.Lstat(filepath.Dir(path))
156+ if err != nil {
157+ t.Fatalf("stat parent: %v", err)
158+ }
159+ if perm := dirInfo.Mode().Perm(); perm != 0o700 {
160+ t.Fatalf("parent permissions = %04o, want 0700", perm)
161+ }
162+
163+ raw, err := os.ReadFile(path)
164+ if err != nil {
165+ t.Fatalf("read store: %v", err)
166+ }
167+ var decoded hostsFile
168+ if err := json.Unmarshal(raw, &decoded); err != nil {
169+ t.Fatalf("decode store: %v", err)
170+ }
171+ if decoded.Version != 2 {
172+ t.Fatalf("version = %d, want 2", decoded.Version)
173+ }
174+ if !slices.Equal(decoded.Pinned, wantPinned) {
175+ t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
176+ }
177+ if !slices.Equal(decoded.Learned, wantLearned) {
178+ t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
179+ }
180+ })
181+
182+ t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
183+ t.Parallel()
184+ path := storePath(t)
185+ store := NewFileHostStore(path)
186+
187+ if err := store.AddPinned("arch"); err != nil {
188+ t.Fatalf("AddPinned(arch) error = %v", err)
189+ }
190+ if err := store.Remember("arch"); err != nil {
191+ t.Fatalf("Remember(arch) error = %v", err)
192+ }
193+ pinned, learned, err := store.Hosts()
194+ if err != nil {
195+ t.Fatalf("Hosts() error = %v", err)
196+ }
197+ if !slices.Equal(pinned, []string{"arch"}) {
198+ t.Fatalf("pinned = %v, want [arch]", pinned)
199+ }
200+ if !slices.Equal(learned, []string{"arch"}) {
201+ t.Fatalf("learned = %v, want [arch]", learned)
202+ }
203+ })
204+
205+ t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
206+ t.Parallel()
207+ path := storePath(t)
208+ store := NewFileHostStore(path)
209+
210+ if err := store.AddPinned("arch"); err != nil {
211+ t.Fatalf("AddPinned(arch) error = %v", err)
212+ }
213+ if err := store.AddPinned("arch"); err != nil {
214+ t.Fatalf("AddPinned(arch) again error = %v", err)
215+ }
216+ if err := store.Remember("mule"); err != nil {
217+ t.Fatalf("Remember(mule) error = %v", err)
218+ }
219+ if err := store.Remember("mule"); err != nil {
220+ t.Fatalf("Remember(mule) again error = %v", err)
221+ }
222+ if err := store.Remember("arch"); err != nil {
223+ t.Fatalf("Remember(arch) error = %v", err)
224+ }
225+
226+ if err := store.RemovePinned("missing"); err != nil {
227+ t.Fatalf("RemovePinned(missing) error = %v", err)
228+ }
229+ if err := store.Forget("missing"); err != nil {
230+ t.Fatalf("Forget(missing) error = %v", err)
231+ }
232+ if err := store.Remove("missing"); err != nil {
233+ t.Fatalf("Remove(missing) error = %v", err)
234+ }
235+
236+ if err := store.Remove("arch"); err != nil {
237+ t.Fatalf("Remove(arch) error = %v", err)
238+ }
239+ pinned, learned, err := store.Hosts()
240+ if err != nil {
241+ t.Fatalf("Hosts() error = %v", err)
242+ }
243+ if len(pinned) != 0 {
244+ t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
245+ }
246+ if !slices.Equal(learned, []string{"mule"}) {
247+ t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
248+ }
249+
250+ if err := store.Forget("mule"); err != nil {
251+ t.Fatalf("Forget(mule) error = %v", err)
252+ }
253+ if err := store.Forget("mule"); err != nil {
254+ t.Fatalf("Forget(mule) again error = %v", err)
255+ }
256+ _, learned, err = store.Hosts()
257+ if err != nil {
258+ t.Fatalf("Hosts() after forget error = %v", err)
259+ }
260+ if len(learned) != 0 {
261+ t.Fatalf("learned = %v, want empty", learned)
262+ }
263+ })
264+
265+ t.Run("malformed file is preserved and fails closed", func(t *testing.T) {
266+ t.Parallel()
267+ path := storePath(t)
268+ malformed := []byte(`{"version":2,"pinned":`)
269+ writeHosts(t, path, malformed)
270+
271+ store := NewFileHostStore(path)
272+ if _, _, err := store.Hosts(); err == nil {
273+ t.Fatal("Hosts() error = nil, want malformed error")
274+ }
275+ if err := store.AddPinned("arch"); err == nil {
276+ t.Fatal("AddPinned() error = nil, want malformed error")
277+ }
278+ if err := store.Remember("arch"); err == nil {
279+ t.Fatal("Remember() error = nil, want malformed error")
280+ }
281+ if err := store.Remove("arch"); err == nil {
282+ t.Fatal("Remove() error = nil, want malformed error")
283+ }
284+
285+ raw, err := os.ReadFile(path)
286+ if err != nil {
287+ t.Fatalf("read preserved file: %v", err)
288+ }
289+ if string(raw) != string(malformed) {
290+ t.Fatalf("malformed file changed: got %q, want %q", raw, malformed)
291+ }
292+ })
293+
294+ t.Run("legacy version 1 migrates on mutation", func(t *testing.T) {
295+ t.Parallel()
296+ cases := []struct {
297+ name string
298+ raw string
299+ wantLearned []string
300+ }{
301+ {name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}},
302+ {name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}},
303+ }
304+ for _, tc := range cases {
305+ t.Run(tc.name, func(t *testing.T) {
306+ t.Parallel()
307+ path := storePath(t)
308+ writeHosts(t, path, []byte(tc.raw))
309+ store := NewFileHostStore(path)
310+
311+ pinned, learned, err := store.Hosts()
312+ if err != nil {
313+ t.Fatalf("Hosts() error = %v", err)
314+ }
315+ if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) {
316+ t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
317+ }
318+ raw, err := os.ReadFile(path)
319+ if err != nil {
320+ t.Fatalf("read legacy store: %v", err)
321+ }
322+ if string(raw) != tc.raw {
323+ t.Fatalf("read-only Hosts rewrote file: got %q, want %q", raw, tc.raw)
324+ }
325+
326+ if err := store.AddPinned("zebra"); err != nil {
327+ t.Fatalf("AddPinned() error = %v", err)
328+ }
329+ raw, err = os.ReadFile(path)
330+ if err != nil {
331+ t.Fatalf("read migrated store: %v", err)
332+ }
333+ var decoded hostsFile
334+ if err := json.Unmarshal(raw, &decoded); err != nil {
335+ t.Fatalf("decode migrated store: %v", err)
336+ }
337+ if decoded.Version != hostsFileVersion {
338+ t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
339+ }
340+ if !slices.Equal(decoded.Pinned, []string{"zebra"}) || !slices.Equal(decoded.Learned, tc.wantLearned) {
341+ t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned)
342+ }
343+ })
344+ }
345+ })
346+
347+ t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
348+ t.Parallel()
349+ cases := []struct {
350+ name string
351+ raw string
352+ wantError string
353+ }{
354+ {name: "unsupported version", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "unsupported hosts file version 3"},
355+ {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
356+ {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
357+ {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
358+ {name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"},
359+ {name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"},
360+ {name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"},
361+ {name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"},
362+ {name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"},
363+ {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
364+ {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
365+ {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
366+ }
367+ for _, tc := range cases {
368+ t.Run(tc.name, func(t *testing.T) {
369+ t.Parallel()
370+ path := storePath(t)
371+ writeHosts(t, path, []byte(tc.raw))
372+
373+ store := NewFileHostStore(path)
374+ if _, _, err := store.Hosts(); err == nil {
375+ t.Fatal("Hosts() error = nil, want schema error")
376+ } else if !strings.Contains(err.Error(), tc.wantError) {
377+ t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError)
378+ }
379+ if err := store.Remember("arch"); err == nil {
380+ t.Fatal("Remember() error = nil, want schema error")
381+ }
382+ raw, err := os.ReadFile(path)
383+ if err != nil {
384+ t.Fatalf("read preserved file: %v", err)
385+ }
386+ if string(raw) != tc.raw {
387+ t.Fatalf("file changed: got %q, want %q", raw, tc.raw)
388+ }
389+ })
390+ }
391+ })
392+
393+ t.Run("sorts unsorted learned on read without rewriting until mutation", func(t *testing.T) {
394+ t.Parallel()
395+ path := storePath(t)
396+ unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n")
397+ writeHosts(t, path, unsorted)
398+
399+ store := NewFileHostStore(path)
400+ pinned, learned, err := store.Hosts()
401+ if err != nil {
402+ t.Fatalf("Hosts() error = %v", err)
403+ }
404+ if !slices.Equal(pinned, []string{"zebra", "arch"}) {
405+ t.Fatalf("pinned = %v, want [zebra arch]", pinned)
406+ }
407+ if !slices.Equal(learned, []string{"beta", "mule"}) {
408+ t.Fatalf("learned = %v, want [beta mule]", learned)
409+ }
410+ raw, err := os.ReadFile(path)
411+ if err != nil {
412+ t.Fatalf("read store: %v", err)
413+ }
414+ if string(raw) != string(unsorted) {
415+ t.Fatalf("read-only Hosts rewrote file: got %q", raw)
416+ }
417+
418+ if err := store.Remember("gamma"); err != nil {
419+ t.Fatalf("Remember(gamma) error = %v", err)
420+ }
421+ raw, err = os.ReadFile(path)
422+ if err != nil {
423+ t.Fatalf("read after mutation: %v", err)
424+ }
425+ var decoded hostsFile
426+ if err := json.Unmarshal(raw, &decoded); err != nil {
427+ t.Fatalf("decode: %v", err)
428+ }
429+ if !slices.Equal(decoded.Pinned, []string{"zebra", "arch"}) {
430+ t.Fatalf("pinned after mutation = %v", decoded.Pinned)
431+ }
432+ if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) {
433+ t.Fatalf("learned after mutation = %v", decoded.Learned)
434+ }
435+ })
436+
437+ t.Run("rejects invalid targets", func(t *testing.T) {
438+ t.Parallel()
439+ path := storePath(t)
440+ store := NewFileHostStore(path)
441+
442+ for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} {
443+ if err := store.AddPinned(target); err == nil {
444+ t.Fatalf("AddPinned(%q) error = nil, want validation error", target)
445+ }
446+ if err := store.Remember(target); err == nil {
447+ t.Fatalf("Remember(%q) error = nil, want validation error", target)
448+ }
449+ if err := store.Remove(target); err == nil {
450+ t.Fatalf("Remove(%q) error = nil, want validation error", target)
451+ }
452+ }
453+ })
454+
455+ t.Run("rejects symlink parent", func(t *testing.T) {
456+ t.Parallel()
457+ root := t.TempDir()
458+ realDir := filepath.Join(root, "real")
459+ if err := os.MkdirAll(realDir, 0o700); err != nil {
460+ t.Fatalf("mkdir real: %v", err)
461+ }
462+ linkDir := filepath.Join(root, "link")
463+ if err := os.Symlink(realDir, linkDir); err != nil {
464+ t.Fatalf("symlink parent: %v", err)
465+ }
466+ path := filepath.Join(linkDir, "hosts.json")
467+ store := NewFileHostStore(path)
468+
469+ if err := store.Remember("arch"); err == nil {
470+ t.Fatal("Remember() error = nil, want symlink parent rejection")
471+ }
472+ })
473+
474+ t.Run("rejects non-private parent permissions", func(t *testing.T) {
475+ t.Parallel()
476+ path := storePath(t)
477+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
478+ t.Fatalf("mkdir: %v", err)
479+ }
480+ store := NewFileHostStore(path)
481+ if err := store.Remember("arch"); err == nil {
482+ t.Fatal("Remember() error = nil, want permission rejection")
483+ }
484+ })
485+
486+ t.Run("atomic replacement leaves no temp files", func(t *testing.T) {
487+ t.Parallel()
488+ path := storePath(t)
489+ store := NewFileHostStore(path)
490+ if err := store.AddPinned("arch"); err != nil {
491+ t.Fatalf("AddPinned() error = %v", err)
492+ }
493+ if err := store.Remember("mule"); err != nil {
494+ t.Fatalf("Remember() error = %v", err)
495+ }
496+
497+ entries, err := os.ReadDir(filepath.Dir(path))
498+ if err != nil {
499+ t.Fatalf("readdir: %v", err)
500+ }
501+ base := filepath.Base(path)
502+ for _, entry := range entries {
503+ name := entry.Name()
504+ if name == base || strings.HasSuffix(name, ".lock") {
505+ continue
506+ }
507+ if strings.Contains(name, ".tmp") {
508+ t.Fatalf("leftover temp file %q", name)
509+ }
510+ t.Fatalf("unexpected leftover entry %q", name)
511+ }
512+ })
513+
514+ t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) {
515+ t.Parallel()
516+ path := storePath(t)
517+ dir := filepath.Dir(path)
518+ if err := os.MkdirAll(dir, 0o700); err != nil {
519+ t.Fatalf("mkdir: %v", err)
520+ }
521+ lockPath := path + ".lock"
522+ if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
523+ t.Fatalf("seed lock: %v", err)
524+ }
525+ info, err := os.Stat(lockPath)
526+ if err != nil {
527+ t.Fatalf("stat seeded lock: %v", err)
528+ }
529+ if perm := info.Mode().Perm(); perm != 0o644 {
530+ t.Fatalf("seeded lock permissions = %04o, want 0644", perm)
531+ }
532+
533+ store := NewFileHostStore(path)
534+ if err := store.Remember("arch"); err != nil {
535+ t.Fatalf("Remember() error = %v", err)
536+ }
537+
538+ info, err = os.Stat(lockPath)
539+ if err != nil {
540+ t.Fatalf("stat lock: %v", err)
541+ }
542+ if perm := info.Mode().Perm(); perm != 0o600 {
543+ t.Fatalf("lock permissions = %04o, want 0600", perm)
544+ }
545+ })
546+
547+ t.Run("failed mutation rolls back without partial write", func(t *testing.T) {
548+ t.Parallel()
549+ path := storePath(t)
550+ store := NewFileHostStore(path)
551+ if err := store.AddPinned("arch"); err != nil {
552+ t.Fatalf("AddPinned() error = %v", err)
553+ }
554+ if err := store.Remember("mule"); err != nil {
555+ t.Fatalf("Remember() error = %v", err)
556+ }
557+ before, err := os.ReadFile(path)
558+ if err != nil {
559+ t.Fatalf("read before: %v", err)
560+ }
561+
562+ // Replace with malformed content while holding no store lock; next mutation must fail closed.
563+ malformed := []byte(`{"version":2,"pinned":`)
564+ if err := os.WriteFile(path, malformed, 0o600); err != nil {
565+ t.Fatalf("inject malformed: %v", err)
566+ }
567+ if err := store.AddPinned("zebra"); err == nil {
568+ t.Fatal("AddPinned() error = nil, want malformed error")
569+ }
570+ after, err := os.ReadFile(path)
571+ if err != nil {
572+ t.Fatalf("read after failed mutation: %v", err)
573+ }
574+ if string(after) != string(malformed) {
575+ t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before)
576+ }
577+ })
578+
579+ t.Run("concurrent independent stores lose no hosts", func(t *testing.T) {
580+ t.Parallel()
581+ path := storePath(t)
582+
583+ left := make([]string, 0, 40)
584+ right := make([]string, 0, 40)
585+ for i := 0; i < 40; i++ {
586+ left = append(left, "left-"+strconv.Itoa(i))
587+ right = append(right, "right-"+strconv.Itoa(i))
588+ }
589+
590+ start := make(chan struct{})
591+ var wg sync.WaitGroup
592+ errs := make(chan error, 2)
593+
594+ wg.Add(2)
595+ go func() {
596+ defer wg.Done()
597+ store := NewFileHostStore(path)
598+ <-start
599+ for _, host := range left {
600+ if err := store.Remember(host); err != nil {
601+ errs <- err
602+ return
603+ }
604+ }
605+ }()
606+ go func() {
607+ defer wg.Done()
608+ store := NewFileHostStore(path)
609+ <-start
610+ for _, host := range right {
611+ if err := store.AddPinned(host); err != nil {
612+ errs <- err
613+ return
614+ }
615+ }
616+ }()
617+
618+ close(start)
619+ wg.Wait()
620+ close(errs)
621+ for err := range errs {
622+ t.Fatalf("concurrent mutation error = %v", err)
623+ }
624+
625+ pinned, learned, err := NewFileHostStore(path).Hosts()
626+ if err != nil {
627+ t.Fatalf("Hosts() error = %v", err)
628+ }
629+ wantLearned := append([]string{}, left...)
630+ sort.Strings(wantLearned)
631+ if !slices.Equal(learned, wantLearned) {
632+ t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
633+ }
634+ if len(pinned) != len(right) {
635+ t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
636+ }
637+ pinnedSet := make(map[string]struct{}, len(pinned))
638+ for _, host := range pinned {
639+ pinnedSet[host] = struct{}{}
640+ }
641+ for _, host := range right {
642+ if _, ok := pinnedSet[host]; !ok {
643+ t.Fatalf("pinned missing %q", host)
644+ }
645+ }
646+ })
647+}
@@ -0,0 +1,647 @@
1+package remote
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "slices"
9+ "sort"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "testing"
14+)
15+
16+func TestHostStoreCrossProcessHelper(t *testing.T) {
17+ if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
18+ t.Skip("helper process")
19+ }
20+
21+ path := os.Getenv("VEV_HOST_STORE_PATH")
22+ hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",")
23+ store := NewFileHostStore(path)
24+ for _, host := range hosts {
25+ if host == "" {
26+ continue
27+ }
28+ if err := store.Remember(host); err != nil {
29+ t.Fatalf("Remember(%q) error = %v", host, err)
30+ }
31+ }
32+}
33+
34+func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
35+ path := filepath.Join(t.TempDir(), "vev", "hosts.json")
36+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
37+ t.Fatalf("mkdir: %v", err)
38+ }
39+
40+ left := make([]string, 0, 40)
41+ right := make([]string, 0, 40)
42+ for i := 0; i < 40; i++ {
43+ left = append(left, "left-"+strconv.Itoa(i))
44+ right = append(right, "right-"+strconv.Itoa(i))
45+ }
46+
47+ startHelper := func(hosts []string) *exec.Cmd {
48+ cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$")
49+ cmd.Env = append(os.Environ(),
50+ "VEV_HOST_STORE_HELPER=1",
51+ "VEV_HOST_STORE_PATH="+path,
52+ "VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","),
53+ )
54+ cmd.Stderr = os.Stderr
55+ return cmd
56+ }
57+
58+ leftCmd := startHelper(left)
59+ rightCmd := startHelper(right)
60+ if err := leftCmd.Start(); err != nil {
61+ t.Fatalf("start left helper: %v", err)
62+ }
63+ if err := rightCmd.Start(); err != nil {
64+ t.Fatalf("start right helper: %v", err)
65+ }
66+ if err := leftCmd.Wait(); err != nil {
67+ t.Fatalf("left helper: %v", err)
68+ }
69+ if err := rightCmd.Wait(); err != nil {
70+ t.Fatalf("right helper: %v", err)
71+ }
72+
73+ _, learned, err := NewFileHostStore(path).Hosts()
74+ if err != nil {
75+ t.Fatalf("Hosts() error = %v", err)
76+ }
77+ want := append(append([]string{}, left...), right...)
78+ sort.Strings(want)
79+ if !slices.Equal(learned, want) {
80+ t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
81+ }
82+}
83+
84+func TestHostStore(t *testing.T) {
85+ t.Parallel()
86+
87+ storePath := func(t *testing.T) string {
88+ t.Helper()
89+ dir := filepath.Join(t.TempDir(), "vev")
90+ return filepath.Join(dir, "hosts.json")
91+ }
92+
93+ writeHosts := func(t *testing.T, path string, raw []byte) {
94+ t.Helper()
95+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
96+ t.Fatalf("mkdir: %v", err)
97+ }
98+ if err := os.WriteFile(path, raw, 0o600); err != nil {
99+ t.Fatalf("write hosts: %v", err)
100+ }
101+ }
102+
103+ t.Run("missing file returns empty lists", func(t *testing.T) {
104+ t.Parallel()
105+ path := storePath(t)
106+ store := NewFileHostStore(path)
107+
108+ pinned, learned, err := store.Hosts()
109+ if err != nil {
110+ t.Fatalf("Hosts() error = %v", err)
111+ }
112+ if len(pinned) != 0 || len(learned) != 0 {
113+ t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned)
114+ }
115+ })
116+
117+ t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) {
118+ t.Parallel()
119+ path := storePath(t)
120+ store := NewFileHostStore(path)
121+
122+ if err := store.AddPinned("zebra"); err != nil {
123+ t.Fatalf("AddPinned(zebra) error = %v", err)
124+ }
125+ if err := store.AddPinned("arch"); err != nil {
126+ t.Fatalf("AddPinned(arch) error = %v", err)
127+ }
128+ if err := store.Remember("mule"); err != nil {
129+ t.Fatalf("Remember(mule) error = %v", err)
130+ }
131+ if err := store.Remember("beta"); err != nil {
132+ t.Fatalf("Remember(beta) error = %v", err)
133+ }
134+
135+ pinned, learned, err := store.Hosts()
136+ if err != nil {
137+ t.Fatalf("Hosts() error = %v", err)
138+ }
139+ wantPinned := []string{"zebra", "arch"}
140+ wantLearned := []string{"beta", "mule"}
141+ if !slices.Equal(pinned, wantPinned) {
142+ t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
143+ }
144+ if !slices.Equal(learned, wantLearned) {
145+ t.Fatalf("learned = %v, want %v", learned, wantLearned)
146+ }
147+
148+ info, err := os.Stat(path)
149+ if err != nil {
150+ t.Fatalf("stat store: %v", err)
151+ }
152+ if perm := info.Mode().Perm(); perm != 0o600 {
153+ t.Fatalf("store permissions = %04o, want 0600", perm)
154+ }
155+ dirInfo, err := os.Lstat(filepath.Dir(path))
156+ if err != nil {
157+ t.Fatalf("stat parent: %v", err)
158+ }
159+ if perm := dirInfo.Mode().Perm(); perm != 0o700 {
160+ t.Fatalf("parent permissions = %04o, want 0700", perm)
161+ }
162+
163+ raw, err := os.ReadFile(path)
164+ if err != nil {
165+ t.Fatalf("read store: %v", err)
166+ }
167+ var decoded hostsFile
168+ if err := json.Unmarshal(raw, &decoded); err != nil {
169+ t.Fatalf("decode store: %v", err)
170+ }
171+ if decoded.Version != 2 {
172+ t.Fatalf("version = %d, want 2", decoded.Version)
173+ }
174+ if !slices.Equal(decoded.Pinned, wantPinned) {
175+ t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
176+ }
177+ if !slices.Equal(decoded.Learned, wantLearned) {
178+ t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
179+ }
180+ })
181+
182+ t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
183+ t.Parallel()
184+ path := storePath(t)
185+ store := NewFileHostStore(path)
186+
187+ if err := store.AddPinned("arch"); err != nil {
188+ t.Fatalf("AddPinned(arch) error = %v", err)
189+ }
190+ if err := store.Remember("arch"); err != nil {
191+ t.Fatalf("Remember(arch) error = %v", err)
192+ }
193+ pinned, learned, err := store.Hosts()
194+ if err != nil {
195+ t.Fatalf("Hosts() error = %v", err)
196+ }
197+ if !slices.Equal(pinned, []string{"arch"}) {
198+ t.Fatalf("pinned = %v, want [arch]", pinned)
199+ }
200+ if !slices.Equal(learned, []string{"arch"}) {
201+ t.Fatalf("learned = %v, want [arch]", learned)
202+ }
203+ })
204+
205+ t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
206+ t.Parallel()
207+ path := storePath(t)
208+ store := NewFileHostStore(path)
209+
210+ if err := store.AddPinned("arch"); err != nil {
211+ t.Fatalf("AddPinned(arch) error = %v", err)
212+ }
213+ if err := store.AddPinned("arch"); err != nil {
214+ t.Fatalf("AddPinned(arch) again error = %v", err)
215+ }
216+ if err := store.Remember("mule"); err != nil {
217+ t.Fatalf("Remember(mule) error = %v", err)
218+ }
219+ if err := store.Remember("mule"); err != nil {
220+ t.Fatalf("Remember(mule) again error = %v", err)
221+ }
222+ if err := store.Remember("arch"); err != nil {
223+ t.Fatalf("Remember(arch) error = %v", err)
224+ }
225+
226+ if err := store.RemovePinned("missing"); err != nil {
227+ t.Fatalf("RemovePinned(missing) error = %v", err)
228+ }
229+ if err := store.Forget("missing"); err != nil {
230+ t.Fatalf("Forget(missing) error = %v", err)
231+ }
232+ if err := store.Remove("missing"); err != nil {
233+ t.Fatalf("Remove(missing) error = %v", err)
234+ }
235+
236+ if err := store.Remove("arch"); err != nil {
237+ t.Fatalf("Remove(arch) error = %v", err)
238+ }
239+ pinned, learned, err := store.Hosts()
240+ if err != nil {
241+ t.Fatalf("Hosts() error = %v", err)
242+ }
243+ if len(pinned) != 0 {
244+ t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
245+ }
246+ if !slices.Equal(learned, []string{"mule"}) {
247+ t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
248+ }
249+
250+ if err := store.Forget("mule"); err != nil {
251+ t.Fatalf("Forget(mule) error = %v", err)
252+ }
253+ if err := store.Forget("mule"); err != nil {
254+ t.Fatalf("Forget(mule) again error = %v", err)
255+ }
256+ _, learned, err = store.Hosts()
257+ if err != nil {
258+ t.Fatalf("Hosts() after forget error = %v", err)
259+ }
260+ if len(learned) != 0 {
261+ t.Fatalf("learned = %v, want empty", learned)
262+ }
263+ })
264+
265+ t.Run("malformed file is preserved and fails closed", func(t *testing.T) {
266+ t.Parallel()
267+ path := storePath(t)
268+ malformed := []byte(`{"version":2,"pinned":`)
269+ writeHosts(t, path, malformed)
270+
271+ store := NewFileHostStore(path)
272+ if _, _, err := store.Hosts(); err == nil {
273+ t.Fatal("Hosts() error = nil, want malformed error")
274+ }
275+ if err := store.AddPinned("arch"); err == nil {
276+ t.Fatal("AddPinned() error = nil, want malformed error")
277+ }
278+ if err := store.Remember("arch"); err == nil {
279+ t.Fatal("Remember() error = nil, want malformed error")
280+ }
281+ if err := store.Remove("arch"); err == nil {
282+ t.Fatal("Remove() error = nil, want malformed error")
283+ }
284+
285+ raw, err := os.ReadFile(path)
286+ if err != nil {
287+ t.Fatalf("read preserved file: %v", err)
288+ }
289+ if string(raw) != string(malformed) {
290+ t.Fatalf("malformed file changed: got %q, want %q", raw, malformed)
291+ }
292+ })
293+
294+ t.Run("legacy version 1 migrates on mutation", func(t *testing.T) {
295+ t.Parallel()
296+ cases := []struct {
297+ name string
298+ raw string
299+ wantLearned []string
300+ }{
301+ {name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}},
302+ {name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}},
303+ }
304+ for _, tc := range cases {
305+ t.Run(tc.name, func(t *testing.T) {
306+ t.Parallel()
307+ path := storePath(t)
308+ writeHosts(t, path, []byte(tc.raw))
309+ store := NewFileHostStore(path)
310+
311+ pinned, learned, err := store.Hosts()
312+ if err != nil {
313+ t.Fatalf("Hosts() error = %v", err)
314+ }
315+ if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) {
316+ t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
317+ }
318+ raw, err := os.ReadFile(path)
319+ if err != nil {
320+ t.Fatalf("read legacy store: %v", err)
321+ }
322+ if string(raw) != tc.raw {
323+ t.Fatalf("read-only Hosts rewrote file: got %q, want %q", raw, tc.raw)
324+ }
325+
326+ if err := store.AddPinned("zebra"); err != nil {
327+ t.Fatalf("AddPinned() error = %v", err)
328+ }
329+ raw, err = os.ReadFile(path)
330+ if err != nil {
331+ t.Fatalf("read migrated store: %v", err)
332+ }
333+ var decoded hostsFile
334+ if err := json.Unmarshal(raw, &decoded); err != nil {
335+ t.Fatalf("decode migrated store: %v", err)
336+ }
337+ if decoded.Version != hostsFileVersion {
338+ t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
339+ }
340+ if !slices.Equal(decoded.Pinned, []string{"zebra"}) || !slices.Equal(decoded.Learned, tc.wantLearned) {
341+ t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned)
342+ }
343+ })
344+ }
345+ })
346+
347+ t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
348+ t.Parallel()
349+ cases := []struct {
350+ name string
351+ raw string
352+ wantError string
353+ }{
354+ {name: "unsupported version", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "unsupported hosts file version 3"},
355+ {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
356+ {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
357+ {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
358+ {name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"},
359+ {name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"},
360+ {name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"},
361+ {name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"},
362+ {name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"},
363+ {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
364+ {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
365+ {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
366+ }
367+ for _, tc := range cases {
368+ t.Run(tc.name, func(t *testing.T) {
369+ t.Parallel()
370+ path := storePath(t)
371+ writeHosts(t, path, []byte(tc.raw))
372+
373+ store := NewFileHostStore(path)
374+ if _, _, err := store.Hosts(); err == nil {
375+ t.Fatal("Hosts() error = nil, want schema error")
376+ } else if !strings.Contains(err.Error(), tc.wantError) {
377+ t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError)
378+ }
379+ if err := store.Remember("arch"); err == nil {
380+ t.Fatal("Remember() error = nil, want schema error")
381+ }
382+ raw, err := os.ReadFile(path)
383+ if err != nil {
384+ t.Fatalf("read preserved file: %v", err)
385+ }
386+ if string(raw) != tc.raw {
387+ t.Fatalf("file changed: got %q, want %q", raw, tc.raw)
388+ }
389+ })
390+ }
391+ })
392+
393+ t.Run("sorts unsorted learned on read without rewriting until mutation", func(t *testing.T) {
394+ t.Parallel()
395+ path := storePath(t)
396+ unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n")
397+ writeHosts(t, path, unsorted)
398+
399+ store := NewFileHostStore(path)
400+ pinned, learned, err := store.Hosts()
401+ if err != nil {
402+ t.Fatalf("Hosts() error = %v", err)
403+ }
404+ if !slices.Equal(pinned, []string{"zebra", "arch"}) {
405+ t.Fatalf("pinned = %v, want [zebra arch]", pinned)
406+ }
407+ if !slices.Equal(learned, []string{"beta", "mule"}) {
408+ t.Fatalf("learned = %v, want [beta mule]", learned)
409+ }
410+ raw, err := os.ReadFile(path)
411+ if err != nil {
412+ t.Fatalf("read store: %v", err)
413+ }
414+ if string(raw) != string(unsorted) {
415+ t.Fatalf("read-only Hosts rewrote file: got %q", raw)
416+ }
417+
418+ if err := store.Remember("gamma"); err != nil {
419+ t.Fatalf("Remember(gamma) error = %v", err)
420+ }
421+ raw, err = os.ReadFile(path)
422+ if err != nil {
423+ t.Fatalf("read after mutation: %v", err)
424+ }
425+ var decoded hostsFile
426+ if err := json.Unmarshal(raw, &decoded); err != nil {
427+ t.Fatalf("decode: %v", err)
428+ }
429+ if !slices.Equal(decoded.Pinned, []string{"zebra", "arch"}) {
430+ t.Fatalf("pinned after mutation = %v", decoded.Pinned)
431+ }
432+ if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) {
433+ t.Fatalf("learned after mutation = %v", decoded.Learned)
434+ }
435+ })
436+
437+ t.Run("rejects invalid targets", func(t *testing.T) {
438+ t.Parallel()
439+ path := storePath(t)
440+ store := NewFileHostStore(path)
441+
442+ for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} {
443+ if err := store.AddPinned(target); err == nil {
444+ t.Fatalf("AddPinned(%q) error = nil, want validation error", target)
445+ }
446+ if err := store.Remember(target); err == nil {
447+ t.Fatalf("Remember(%q) error = nil, want validation error", target)
448+ }
449+ if err := store.Remove(target); err == nil {
450+ t.Fatalf("Remove(%q) error = nil, want validation error", target)
451+ }
452+ }
453+ })
454+
455+ t.Run("rejects symlink parent", func(t *testing.T) {
456+ t.Parallel()
457+ root := t.TempDir()
458+ realDir := filepath.Join(root, "real")
459+ if err := os.MkdirAll(realDir, 0o700); err != nil {
460+ t.Fatalf("mkdir real: %v", err)
461+ }
462+ linkDir := filepath.Join(root, "link")
463+ if err := os.Symlink(realDir, linkDir); err != nil {
464+ t.Fatalf("symlink parent: %v", err)
465+ }
466+ path := filepath.Join(linkDir, "hosts.json")
467+ store := NewFileHostStore(path)
468+
469+ if err := store.Remember("arch"); err == nil {
470+ t.Fatal("Remember() error = nil, want symlink parent rejection")
471+ }
472+ })
473+
474+ t.Run("rejects non-private parent permissions", func(t *testing.T) {
475+ t.Parallel()
476+ path := storePath(t)
477+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
478+ t.Fatalf("mkdir: %v", err)
479+ }
480+ store := NewFileHostStore(path)
481+ if err := store.Remember("arch"); err == nil {
482+ t.Fatal("Remember() error = nil, want permission rejection")
483+ }
484+ })
485+
486+ t.Run("atomic replacement leaves no temp files", func(t *testing.T) {
487+ t.Parallel()
488+ path := storePath(t)
489+ store := NewFileHostStore(path)
490+ if err := store.AddPinned("arch"); err != nil {
491+ t.Fatalf("AddPinned() error = %v", err)
492+ }
493+ if err := store.Remember("mule"); err != nil {
494+ t.Fatalf("Remember() error = %v", err)
495+ }
496+
497+ entries, err := os.ReadDir(filepath.Dir(path))
498+ if err != nil {
499+ t.Fatalf("readdir: %v", err)
500+ }
501+ base := filepath.Base(path)
502+ for _, entry := range entries {
503+ name := entry.Name()
504+ if name == base || strings.HasSuffix(name, ".lock") {
505+ continue
506+ }
507+ if strings.Contains(name, ".tmp") {
508+ t.Fatalf("leftover temp file %q", name)
509+ }
510+ t.Fatalf("unexpected leftover entry %q", name)
511+ }
512+ })
513+
514+ t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) {
515+ t.Parallel()
516+ path := storePath(t)
517+ dir := filepath.Dir(path)
518+ if err := os.MkdirAll(dir, 0o700); err != nil {
519+ t.Fatalf("mkdir: %v", err)
520+ }
521+ lockPath := path + ".lock"
522+ if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
523+ t.Fatalf("seed lock: %v", err)
524+ }
525+ info, err := os.Stat(lockPath)
526+ if err != nil {
527+ t.Fatalf("stat seeded lock: %v", err)
528+ }
529+ if perm := info.Mode().Perm(); perm != 0o644 {
530+ t.Fatalf("seeded lock permissions = %04o, want 0644", perm)
531+ }
532+
533+ store := NewFileHostStore(path)
534+ if err := store.Remember("arch"); err != nil {
535+ t.Fatalf("Remember() error = %v", err)
536+ }
537+
538+ info, err = os.Stat(lockPath)
539+ if err != nil {
540+ t.Fatalf("stat lock: %v", err)
541+ }
542+ if perm := info.Mode().Perm(); perm != 0o600 {
543+ t.Fatalf("lock permissions = %04o, want 0600", perm)
544+ }
545+ })
546+
547+ t.Run("failed mutation rolls back without partial write", func(t *testing.T) {
548+ t.Parallel()
549+ path := storePath(t)
550+ store := NewFileHostStore(path)
551+ if err := store.AddPinned("arch"); err != nil {
552+ t.Fatalf("AddPinned() error = %v", err)
553+ }
554+ if err := store.Remember("mule"); err != nil {
555+ t.Fatalf("Remember() error = %v", err)
556+ }
557+ before, err := os.ReadFile(path)
558+ if err != nil {
559+ t.Fatalf("read before: %v", err)
560+ }
561+
562+ // Replace with malformed content while holding no store lock; next mutation must fail closed.
563+ malformed := []byte(`{"version":2,"pinned":`)
564+ if err := os.WriteFile(path, malformed, 0o600); err != nil {
565+ t.Fatalf("inject malformed: %v", err)
566+ }
567+ if err := store.AddPinned("zebra"); err == nil {
568+ t.Fatal("AddPinned() error = nil, want malformed error")
569+ }
570+ after, err := os.ReadFile(path)
571+ if err != nil {
572+ t.Fatalf("read after failed mutation: %v", err)
573+ }
574+ if string(after) != string(malformed) {
575+ t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before)
576+ }
577+ })
578+
579+ t.Run("concurrent independent stores lose no hosts", func(t *testing.T) {
580+ t.Parallel()
581+ path := storePath(t)
582+
583+ left := make([]string, 0, 40)
584+ right := make([]string, 0, 40)
585+ for i := 0; i < 40; i++ {
586+ left = append(left, "left-"+strconv.Itoa(i))
587+ right = append(right, "right-"+strconv.Itoa(i))
588+ }
589+
590+ start := make(chan struct{})
591+ var wg sync.WaitGroup
592+ errs := make(chan error, 2)
593+
594+ wg.Add(2)
595+ go func() {
596+ defer wg.Done()
597+ store := NewFileHostStore(path)
598+ <-start
599+ for _, host := range left {
600+ if err := store.Remember(host); err != nil {
601+ errs <- err
602+ return
603+ }
604+ }
605+ }()
606+ go func() {
607+ defer wg.Done()
608+ store := NewFileHostStore(path)
609+ <-start
610+ for _, host := range right {
611+ if err := store.AddPinned(host); err != nil {
612+ errs <- err
613+ return
614+ }
615+ }
616+ }()
617+
618+ close(start)
619+ wg.Wait()
620+ close(errs)
621+ for err := range errs {
622+ t.Fatalf("concurrent mutation error = %v", err)
623+ }
624+
625+ pinned, learned, err := NewFileHostStore(path).Hosts()
626+ if err != nil {
627+ t.Fatalf("Hosts() error = %v", err)
628+ }
629+ wantLearned := append([]string{}, left...)
630+ sort.Strings(wantLearned)
631+ if !slices.Equal(learned, wantLearned) {
632+ t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
633+ }
634+ if len(pinned) != len(right) {
635+ t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
636+ }
637+ pinnedSet := make(map[string]struct{}, len(pinned))
638+ for _, host := range pinned {
639+ pinnedSet[host] = struct{}{}
640+ }
641+ for _, host := range right {
642+ if _, ok := pinnedSet[host]; !ok {
643+ t.Fatalf("pinned missing %q", host)
644+ }
645+ }
646+ })
647+}
M internal/ports/mocks/remote_mock.go +345 -0
@@ -117,3 +117,348 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta
117117 _c.Call.Return(run)
118118 return _c
119119 }
120+
121+// NewMockRemoteHostStore creates a new instance of MockRemoteHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122+// The first argument is typically a *testing.T value.
123+func NewMockRemoteHostStore(t interface {
124+ mock.TestingT
125+ Cleanup(func())
126+}) *MockRemoteHostStore {
127+ mock := &MockRemoteHostStore{}
128+ mock.Mock.Test(t)
129+
130+ t.Cleanup(func() { mock.AssertExpectations(t) })
131+
132+ return mock
133+}
134+
135+// MockRemoteHostStore is an autogenerated mock type for the RemoteHostStore type
136+type MockRemoteHostStore struct {
137+ mock.Mock
138+}
139+
140+type MockRemoteHostStore_Expecter struct {
141+ mock *mock.Mock
142+}
143+
144+func (_m *MockRemoteHostStore) EXPECT() *MockRemoteHostStore_Expecter {
145+ return &MockRemoteHostStore_Expecter{mock: &_m.Mock}
146+}
147+
148+// AddPinned provides a mock function for the type MockRemoteHostStore
149+func (_mock *MockRemoteHostStore) AddPinned(target string) error {
150+ ret := _mock.Called(target)
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for AddPinned")
154+ }
155+
156+ var r0 error
157+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
158+ r0 = returnFunc(target)
159+ } else {
160+ r0 = ret.Error(0)
161+ }
162+ return r0
163+}
164+
165+// MockRemoteHostStore_AddPinned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPinned'
166+type MockRemoteHostStore_AddPinned_Call struct {
167+ *mock.Call
168+}
169+
170+// AddPinned is a helper method to define mock.On call
171+// - target string
172+func (_e *MockRemoteHostStore_Expecter) AddPinned(target any) *MockRemoteHostStore_AddPinned_Call {
173+ return &MockRemoteHostStore_AddPinned_Call{Call: _e.mock.On("AddPinned", target)}
174+}
175+
176+func (_c *MockRemoteHostStore_AddPinned_Call) Run(run func(target string)) *MockRemoteHostStore_AddPinned_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ var arg0 string
179+ if args[0] != nil {
180+ arg0 = args[0].(string)
181+ }
182+ run(
183+ arg0,
184+ )
185+ })
186+ return _c
187+}
188+
189+func (_c *MockRemoteHostStore_AddPinned_Call) Return(err error) *MockRemoteHostStore_AddPinned_Call {
190+ _c.Call.Return(err)
191+ return _c
192+}
193+
194+func (_c *MockRemoteHostStore_AddPinned_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_AddPinned_Call {
195+ _c.Call.Return(run)
196+ return _c
197+}
198+
199+// Forget provides a mock function for the type MockRemoteHostStore
200+func (_mock *MockRemoteHostStore) Forget(target string) error {
201+ ret := _mock.Called(target)
202+
203+ if len(ret) == 0 {
204+ panic("no return value specified for Forget")
205+ }
206+
207+ var r0 error
208+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
209+ r0 = returnFunc(target)
210+ } else {
211+ r0 = ret.Error(0)
212+ }
213+ return r0
214+}
215+
216+// MockRemoteHostStore_Forget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Forget'
217+type MockRemoteHostStore_Forget_Call struct {
218+ *mock.Call
219+}
220+
221+// Forget is a helper method to define mock.On call
222+// - target string
223+func (_e *MockRemoteHostStore_Expecter) Forget(target any) *MockRemoteHostStore_Forget_Call {
224+ return &MockRemoteHostStore_Forget_Call{Call: _e.mock.On("Forget", target)}
225+}
226+
227+func (_c *MockRemoteHostStore_Forget_Call) Run(run func(target string)) *MockRemoteHostStore_Forget_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 string
230+ if args[0] != nil {
231+ arg0 = args[0].(string)
232+ }
233+ run(
234+ arg0,
235+ )
236+ })
237+ return _c
238+}
239+
240+func (_c *MockRemoteHostStore_Forget_Call) Return(err error) *MockRemoteHostStore_Forget_Call {
241+ _c.Call.Return(err)
242+ return _c
243+}
244+
245+func (_c *MockRemoteHostStore_Forget_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Forget_Call {
246+ _c.Call.Return(run)
247+ return _c
248+}
249+
250+// Hosts provides a mock function for the type MockRemoteHostStore
251+func (_mock *MockRemoteHostStore) Hosts() ([]string, []string, error) {
252+ ret := _mock.Called()
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for Hosts")
256+ }
257+
258+ var r0 []string
259+ var r1 []string
260+ var r2 error
261+ if returnFunc, ok := ret.Get(0).(func() ([]string, []string, error)); ok {
262+ return returnFunc()
263+ }
264+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
265+ r0 = returnFunc()
266+ } else {
267+ if ret.Get(0) != nil {
268+ r0 = ret.Get(0).([]string)
269+ }
270+ }
271+ if returnFunc, ok := ret.Get(1).(func() []string); ok {
272+ r1 = returnFunc()
273+ } else {
274+ if ret.Get(1) != nil {
275+ r1 = ret.Get(1).([]string)
276+ }
277+ }
278+ if returnFunc, ok := ret.Get(2).(func() error); ok {
279+ r2 = returnFunc()
280+ } else {
281+ r2 = ret.Error(2)
282+ }
283+ return r0, r1, r2
284+}
285+
286+// MockRemoteHostStore_Hosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hosts'
287+type MockRemoteHostStore_Hosts_Call struct {
288+ *mock.Call
289+}
290+
291+// Hosts is a helper method to define mock.On call
292+func (_e *MockRemoteHostStore_Expecter) Hosts() *MockRemoteHostStore_Hosts_Call {
293+ return &MockRemoteHostStore_Hosts_Call{Call: _e.mock.On("Hosts")}
294+}
295+
296+func (_c *MockRemoteHostStore_Hosts_Call) Run(run func()) *MockRemoteHostStore_Hosts_Call {
297+ _c.Call.Run(func(args mock.Arguments) {
298+ run()
299+ })
300+ return _c
301+}
302+
303+func (_c *MockRemoteHostStore_Hosts_Call) Return(pinned []string, learned []string, err error) *MockRemoteHostStore_Hosts_Call {
304+ _c.Call.Return(pinned, learned, err)
305+ return _c
306+}
307+
308+func (_c *MockRemoteHostStore_Hosts_Call) RunAndReturn(run func() ([]string, []string, error)) *MockRemoteHostStore_Hosts_Call {
309+ _c.Call.Return(run)
310+ return _c
311+}
312+
313+// Remember provides a mock function for the type MockRemoteHostStore
314+func (_mock *MockRemoteHostStore) Remember(target string) error {
315+ ret := _mock.Called(target)
316+
317+ if len(ret) == 0 {
318+ panic("no return value specified for Remember")
319+ }
320+
321+ var r0 error
322+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
323+ r0 = returnFunc(target)
324+ } else {
325+ r0 = ret.Error(0)
326+ }
327+ return r0
328+}
329+
330+// MockRemoteHostStore_Remember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remember'
331+type MockRemoteHostStore_Remember_Call struct {
332+ *mock.Call
333+}
334+
335+// Remember is a helper method to define mock.On call
336+// - target string
337+func (_e *MockRemoteHostStore_Expecter) Remember(target any) *MockRemoteHostStore_Remember_Call {
338+ return &MockRemoteHostStore_Remember_Call{Call: _e.mock.On("Remember", target)}
339+}
340+
341+func (_c *MockRemoteHostStore_Remember_Call) Run(run func(target string)) *MockRemoteHostStore_Remember_Call {
342+ _c.Call.Run(func(args mock.Arguments) {
343+ var arg0 string
344+ if args[0] != nil {
345+ arg0 = args[0].(string)
346+ }
347+ run(
348+ arg0,
349+ )
350+ })
351+ return _c
352+}
353+
354+func (_c *MockRemoteHostStore_Remember_Call) Return(err error) *MockRemoteHostStore_Remember_Call {
355+ _c.Call.Return(err)
356+ return _c
357+}
358+
359+func (_c *MockRemoteHostStore_Remember_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remember_Call {
360+ _c.Call.Return(run)
361+ return _c
362+}
363+
364+// Remove provides a mock function for the type MockRemoteHostStore
365+func (_mock *MockRemoteHostStore) Remove(target string) error {
366+ ret := _mock.Called(target)
367+
368+ if len(ret) == 0 {
369+ panic("no return value specified for Remove")
370+ }
371+
372+ var r0 error
373+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
374+ r0 = returnFunc(target)
375+ } else {
376+ r0 = ret.Error(0)
377+ }
378+ return r0
379+}
380+
381+// MockRemoteHostStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
382+type MockRemoteHostStore_Remove_Call struct {
383+ *mock.Call
384+}
385+
386+// Remove is a helper method to define mock.On call
387+// - target string
388+func (_e *MockRemoteHostStore_Expecter) Remove(target any) *MockRemoteHostStore_Remove_Call {
389+ return &MockRemoteHostStore_Remove_Call{Call: _e.mock.On("Remove", target)}
390+}
391+
392+func (_c *MockRemoteHostStore_Remove_Call) Run(run func(target string)) *MockRemoteHostStore_Remove_Call {
393+ _c.Call.Run(func(args mock.Arguments) {
394+ var arg0 string
395+ if args[0] != nil {
396+ arg0 = args[0].(string)
397+ }
398+ run(
399+ arg0,
400+ )
401+ })
402+ return _c
403+}
404+
405+func (_c *MockRemoteHostStore_Remove_Call) Return(err error) *MockRemoteHostStore_Remove_Call {
406+ _c.Call.Return(err)
407+ return _c
408+}
409+
410+func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remove_Call {
411+ _c.Call.Return(run)
412+ return _c
413+}
414+
415+// RemovePinned provides a mock function for the type MockRemoteHostStore
416+func (_mock *MockRemoteHostStore) RemovePinned(target string) error {
417+ ret := _mock.Called(target)
418+
419+ if len(ret) == 0 {
420+ panic("no return value specified for RemovePinned")
421+ }
422+
423+ var r0 error
424+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
425+ r0 = returnFunc(target)
426+ } else {
427+ r0 = ret.Error(0)
428+ }
429+ return r0
430+}
431+
432+// MockRemoteHostStore_RemovePinned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemovePinned'
433+type MockRemoteHostStore_RemovePinned_Call struct {
434+ *mock.Call
435+}
436+
437+// RemovePinned is a helper method to define mock.On call
438+// - target string
439+func (_e *MockRemoteHostStore_Expecter) RemovePinned(target any) *MockRemoteHostStore_RemovePinned_Call {
440+ return &MockRemoteHostStore_RemovePinned_Call{Call: _e.mock.On("RemovePinned", target)}
441+}
442+
443+func (_c *MockRemoteHostStore_RemovePinned_Call) Run(run func(target string)) *MockRemoteHostStore_RemovePinned_Call {
444+ _c.Call.Run(func(args mock.Arguments) {
445+ var arg0 string
446+ if args[0] != nil {
447+ arg0 = args[0].(string)
448+ }
449+ run(
450+ arg0,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *MockRemoteHostStore_RemovePinned_Call) Return(err error) *MockRemoteHostStore_RemovePinned_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_RemovePinned_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
@@ -117,3 +117,348 @@ func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(ta
117 _c.Call.Return(run)117 _c.Call.Return(run)
118 return _c118 return _c
119 }119 }
120+
121+// NewMockRemoteHostStore creates a new instance of MockRemoteHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122+// The first argument is typically a *testing.T value.
123+func NewMockRemoteHostStore(t interface {
124+ mock.TestingT
125+ Cleanup(func())
126+}) *MockRemoteHostStore {
127+ mock := &MockRemoteHostStore{}
128+ mock.Mock.Test(t)
129+
130+ t.Cleanup(func() { mock.AssertExpectations(t) })
131+
132+ return mock
133+}
134+
135+// MockRemoteHostStore is an autogenerated mock type for the RemoteHostStore type
136+type MockRemoteHostStore struct {
137+ mock.Mock
138+}
139+
140+type MockRemoteHostStore_Expecter struct {
141+ mock *mock.Mock
142+}
143+
144+func (_m *MockRemoteHostStore) EXPECT() *MockRemoteHostStore_Expecter {
145+ return &MockRemoteHostStore_Expecter{mock: &_m.Mock}
146+}
147+
148+// AddPinned provides a mock function for the type MockRemoteHostStore
149+func (_mock *MockRemoteHostStore) AddPinned(target string) error {
150+ ret := _mock.Called(target)
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for AddPinned")
154+ }
155+
156+ var r0 error
157+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
158+ r0 = returnFunc(target)
159+ } else {
160+ r0 = ret.Error(0)
161+ }
162+ return r0
163+}
164+
165+// MockRemoteHostStore_AddPinned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPinned'
166+type MockRemoteHostStore_AddPinned_Call struct {
167+ *mock.Call
168+}
169+
170+// AddPinned is a helper method to define mock.On call
171+// - target string
172+func (_e *MockRemoteHostStore_Expecter) AddPinned(target any) *MockRemoteHostStore_AddPinned_Call {
173+ return &MockRemoteHostStore_AddPinned_Call{Call: _e.mock.On("AddPinned", target)}
174+}
175+
176+func (_c *MockRemoteHostStore_AddPinned_Call) Run(run func(target string)) *MockRemoteHostStore_AddPinned_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ var arg0 string
179+ if args[0] != nil {
180+ arg0 = args[0].(string)
181+ }
182+ run(
183+ arg0,
184+ )
185+ })
186+ return _c
187+}
188+
189+func (_c *MockRemoteHostStore_AddPinned_Call) Return(err error) *MockRemoteHostStore_AddPinned_Call {
190+ _c.Call.Return(err)
191+ return _c
192+}
193+
194+func (_c *MockRemoteHostStore_AddPinned_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_AddPinned_Call {
195+ _c.Call.Return(run)
196+ return _c
197+}
198+
199+// Forget provides a mock function for the type MockRemoteHostStore
200+func (_mock *MockRemoteHostStore) Forget(target string) error {
201+ ret := _mock.Called(target)
202+
203+ if len(ret) == 0 {
204+ panic("no return value specified for Forget")
205+ }
206+
207+ var r0 error
208+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
209+ r0 = returnFunc(target)
210+ } else {
211+ r0 = ret.Error(0)
212+ }
213+ return r0
214+}
215+
216+// MockRemoteHostStore_Forget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Forget'
217+type MockRemoteHostStore_Forget_Call struct {
218+ *mock.Call
219+}
220+
221+// Forget is a helper method to define mock.On call
222+// - target string
223+func (_e *MockRemoteHostStore_Expecter) Forget(target any) *MockRemoteHostStore_Forget_Call {
224+ return &MockRemoteHostStore_Forget_Call{Call: _e.mock.On("Forget", target)}
225+}
226+
227+func (_c *MockRemoteHostStore_Forget_Call) Run(run func(target string)) *MockRemoteHostStore_Forget_Call {
228+ _c.Call.Run(func(args mock.Arguments) {
229+ var arg0 string
230+ if args[0] != nil {
231+ arg0 = args[0].(string)
232+ }
233+ run(
234+ arg0,
235+ )
236+ })
237+ return _c
238+}
239+
240+func (_c *MockRemoteHostStore_Forget_Call) Return(err error) *MockRemoteHostStore_Forget_Call {
241+ _c.Call.Return(err)
242+ return _c
243+}
244+
245+func (_c *MockRemoteHostStore_Forget_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Forget_Call {
246+ _c.Call.Return(run)
247+ return _c
248+}
249+
250+// Hosts provides a mock function for the type MockRemoteHostStore
251+func (_mock *MockRemoteHostStore) Hosts() ([]string, []string, error) {
252+ ret := _mock.Called()
253+
254+ if len(ret) == 0 {
255+ panic("no return value specified for Hosts")
256+ }
257+
258+ var r0 []string
259+ var r1 []string
260+ var r2 error
261+ if returnFunc, ok := ret.Get(0).(func() ([]string, []string, error)); ok {
262+ return returnFunc()
263+ }
264+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
265+ r0 = returnFunc()
266+ } else {
267+ if ret.Get(0) != nil {
268+ r0 = ret.Get(0).([]string)
269+ }
270+ }
271+ if returnFunc, ok := ret.Get(1).(func() []string); ok {
272+ r1 = returnFunc()
273+ } else {
274+ if ret.Get(1) != nil {
275+ r1 = ret.Get(1).([]string)
276+ }
277+ }
278+ if returnFunc, ok := ret.Get(2).(func() error); ok {
279+ r2 = returnFunc()
280+ } else {
281+ r2 = ret.Error(2)
282+ }
283+ return r0, r1, r2
284+}
285+
286+// MockRemoteHostStore_Hosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hosts'
287+type MockRemoteHostStore_Hosts_Call struct {
288+ *mock.Call
289+}
290+
291+// Hosts is a helper method to define mock.On call
292+func (_e *MockRemoteHostStore_Expecter) Hosts() *MockRemoteHostStore_Hosts_Call {
293+ return &MockRemoteHostStore_Hosts_Call{Call: _e.mock.On("Hosts")}
294+}
295+
296+func (_c *MockRemoteHostStore_Hosts_Call) Run(run func()) *MockRemoteHostStore_Hosts_Call {
297+ _c.Call.Run(func(args mock.Arguments) {
298+ run()
299+ })
300+ return _c
301+}
302+
303+func (_c *MockRemoteHostStore_Hosts_Call) Return(pinned []string, learned []string, err error) *MockRemoteHostStore_Hosts_Call {
304+ _c.Call.Return(pinned, learned, err)
305+ return _c
306+}
307+
308+func (_c *MockRemoteHostStore_Hosts_Call) RunAndReturn(run func() ([]string, []string, error)) *MockRemoteHostStore_Hosts_Call {
309+ _c.Call.Return(run)
310+ return _c
311+}
312+
313+// Remember provides a mock function for the type MockRemoteHostStore
314+func (_mock *MockRemoteHostStore) Remember(target string) error {
315+ ret := _mock.Called(target)
316+
317+ if len(ret) == 0 {
318+ panic("no return value specified for Remember")
319+ }
320+
321+ var r0 error
322+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
323+ r0 = returnFunc(target)
324+ } else {
325+ r0 = ret.Error(0)
326+ }
327+ return r0
328+}
329+
330+// MockRemoteHostStore_Remember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remember'
331+type MockRemoteHostStore_Remember_Call struct {
332+ *mock.Call
333+}
334+
335+// Remember is a helper method to define mock.On call
336+// - target string
337+func (_e *MockRemoteHostStore_Expecter) Remember(target any) *MockRemoteHostStore_Remember_Call {
338+ return &MockRemoteHostStore_Remember_Call{Call: _e.mock.On("Remember", target)}
339+}
340+
341+func (_c *MockRemoteHostStore_Remember_Call) Run(run func(target string)) *MockRemoteHostStore_Remember_Call {
342+ _c.Call.Run(func(args mock.Arguments) {
343+ var arg0 string
344+ if args[0] != nil {
345+ arg0 = args[0].(string)
346+ }
347+ run(
348+ arg0,
349+ )
350+ })
351+ return _c
352+}
353+
354+func (_c *MockRemoteHostStore_Remember_Call) Return(err error) *MockRemoteHostStore_Remember_Call {
355+ _c.Call.Return(err)
356+ return _c
357+}
358+
359+func (_c *MockRemoteHostStore_Remember_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remember_Call {
360+ _c.Call.Return(run)
361+ return _c
362+}
363+
364+// Remove provides a mock function for the type MockRemoteHostStore
365+func (_mock *MockRemoteHostStore) Remove(target string) error {
366+ ret := _mock.Called(target)
367+
368+ if len(ret) == 0 {
369+ panic("no return value specified for Remove")
370+ }
371+
372+ var r0 error
373+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
374+ r0 = returnFunc(target)
375+ } else {
376+ r0 = ret.Error(0)
377+ }
378+ return r0
379+}
380+
381+// MockRemoteHostStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
382+type MockRemoteHostStore_Remove_Call struct {
383+ *mock.Call
384+}
385+
386+// Remove is a helper method to define mock.On call
387+// - target string
388+func (_e *MockRemoteHostStore_Expecter) Remove(target any) *MockRemoteHostStore_Remove_Call {
389+ return &MockRemoteHostStore_Remove_Call{Call: _e.mock.On("Remove", target)}
390+}
391+
392+func (_c *MockRemoteHostStore_Remove_Call) Run(run func(target string)) *MockRemoteHostStore_Remove_Call {
393+ _c.Call.Run(func(args mock.Arguments) {
394+ var arg0 string
395+ if args[0] != nil {
396+ arg0 = args[0].(string)
397+ }
398+ run(
399+ arg0,
400+ )
401+ })
402+ return _c
403+}
404+
405+func (_c *MockRemoteHostStore_Remove_Call) Return(err error) *MockRemoteHostStore_Remove_Call {
406+ _c.Call.Return(err)
407+ return _c
408+}
409+
410+func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remove_Call {
411+ _c.Call.Return(run)
412+ return _c
413+}
414+
415+// RemovePinned provides a mock function for the type MockRemoteHostStore
416+func (_mock *MockRemoteHostStore) RemovePinned(target string) error {
417+ ret := _mock.Called(target)
418+
419+ if len(ret) == 0 {
420+ panic("no return value specified for RemovePinned")
421+ }
422+
423+ var r0 error
424+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
425+ r0 = returnFunc(target)
426+ } else {
427+ r0 = ret.Error(0)
428+ }
429+ return r0
430+}
431+
432+// MockRemoteHostStore_RemovePinned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemovePinned'
433+type MockRemoteHostStore_RemovePinned_Call struct {
434+ *mock.Call
435+}
436+
437+// RemovePinned is a helper method to define mock.On call
438+// - target string
439+func (_e *MockRemoteHostStore_Expecter) RemovePinned(target any) *MockRemoteHostStore_RemovePinned_Call {
440+ return &MockRemoteHostStore_RemovePinned_Call{Call: _e.mock.On("RemovePinned", target)}
441+}
442+
443+func (_c *MockRemoteHostStore_RemovePinned_Call) Run(run func(target string)) *MockRemoteHostStore_RemovePinned_Call {
444+ _c.Call.Run(func(args mock.Arguments) {
445+ var arg0 string
446+ if args[0] != nil {
447+ arg0 = args[0].(string)
448+ }
449+ run(
450+ arg0,
451+ )
452+ })
453+ return _c
454+}
455+
456+func (_c *MockRemoteHostStore_RemovePinned_Call) Return(err error) *MockRemoteHostStore_RemovePinned_Call {
457+ _c.Call.Return(err)
458+ return _c
459+}
460+
461+func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_RemovePinned_Call {
462+ _c.Call.Return(run)
463+ return _c
464+}
M internal/ports/remote.go +13 -0
@@ -15,3 +15,16 @@ const (
1515 type RemoteDialerFactory interface {
1616 DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
1717 }
18+
19+// RemoteHostStore persists pinned and learned remote host targets in one
20+// versioned state file across processes.
21+type RemoteHostStore interface {
22+ // Hosts returns pinned hosts in stored order and learned hosts in lexical order.
23+ Hosts() (pinned, learned []string, err error)
24+ AddPinned(target string) error
25+ RemovePinned(target string) error
26+ Remember(target string) error
27+ Forget(target string) error
28+ // Remove deletes target from both pinned and learned lists atomically.
29+ Remove(target string) error
30+}
@@ -15,3 +15,16 @@ const (
15 type RemoteDialerFactory interface {15 type RemoteDialerFactory interface {
16 DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)16 DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17 }17 }
18+
19+// RemoteHostStore persists pinned and learned remote host targets in one
20+// versioned state file across processes.
21+type RemoteHostStore interface {
22+ // Hosts returns pinned hosts in stored order and learned hosts in lexical order.
23+ Hosts() (pinned, learned []string, err error)
24+ AddPinned(target string) error
25+ RemovePinned(target string) error
26+ Remember(target string) error
27+ Forget(target string) error
28+ // Remove deletes target from both pinned and learned lists atomically.
29+ Remove(target string) error
30+}

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