detached b6a8ce8

Merge pull request #157 from bnema/feat/remote-picker-cache

feat(remote): add durable catalog cache and identity
commit
b6a8ce8eda16dfffbeb085a199a91a344800851a
author
bnema · · 2026-07-31 08:57 UTC
committer
GitHub · · 2026-07-31 08:57 UTC
merge of
3776805 + c8ad42f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +1130 -10
A internal/adapters/remote/catalog_cache.go +214 -0
@@ -0,0 +1,214 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ catalogCacheFileName = "remote-catalog-cache.json"
20+ catalogCacheFileVersion = 1
21+)
22+
23+// CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
24+func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) }
25+
26+type catalogCacheFile struct {
27+ Version *int `json:"version"`
28+ Hosts *[]catalogCacheHost `json:"hosts"`
29+}
30+
31+type catalogCacheHost struct {
32+ Target *string `json:"target"`
33+ FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
34+ Sessions *[]catalogCacheSession `json:"sessions"`
35+}
36+
37+type catalogCacheSession struct {
38+ Name *string `json:"name"`
39+ State *string `json:"state"`
40+ Ephemeral *bool `json:"ephemeral"`
41+ Tabs *uint16 `json:"tabs"`
42+ Attached *bool `json:"attached"`
43+}
44+
45+type fileCatalogCache struct {
46+ path string
47+}
48+
49+// NewFileCatalogCache returns a durable remote catalog cache backed by path.
50+func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
51+ return &fileCatalogCache{path: path}
52+}
53+
54+var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
55+
56+// Load reads and validates one complete cache snapshot. A missing cache is an
57+// empty snapshot; malformed cache data is never changed by Load.
58+func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
59+ raw, err := os.ReadFile(c.path)
60+ if errors.Is(err, os.ErrNotExist) {
61+ return []ports.RemoteCatalogCacheEntry{}, nil
62+ }
63+ if err != nil {
64+ return nil, err
65+ }
66+ if !utf8.Valid(raw) {
67+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
68+ }
69+
70+ dec := json.NewDecoder(bytes.NewReader(raw))
71+ dec.DisallowUnknownFields()
72+ var file catalogCacheFile
73+ if err := dec.Decode(&file); err != nil {
74+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
75+ }
76+ if err := rejectTrailingJSON(dec); err != nil {
77+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
78+ }
79+ if file.Version == nil {
80+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
81+ }
82+ if *file.Version != catalogCacheFileVersion {
83+ return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)
84+ }
85+ if file.Hosts == nil {
86+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
87+ }
88+
89+ entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
90+ for _, host := range *file.Hosts {
91+ if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
92+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
93+ }
94+ if !utf8.ValidString(*host.Target) {
95+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
96+ }
97+ if *host.FetchedAtUnixNano <= 0 {
98+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
99+ }
100+ entry := ports.RemoteCatalogCacheEntry{
101+ Host: *host.Target,
102+ FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
103+ Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
104+ }
105+ for _, session := range *host.Sessions {
106+ if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
107+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
108+ }
109+ if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
110+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
111+ }
112+ entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{
113+ Name: *session.Name,
114+ State: *session.State,
115+ Ephemeral: *session.Ephemeral,
116+ Tabs: *session.Tabs,
117+ Attached: *session.Attached,
118+ })
119+ }
120+ entries = append(entries, entry)
121+ }
122+ normalized, err := normalizeCatalogCacheEntries(entries)
123+ if err != nil {
124+ return nil, err
125+ }
126+ return normalized, nil
127+}
128+
129+// Store atomically replaces the cache with a complete normalized snapshot.
130+func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
131+ normalized, err := normalizeCatalogCacheEntries(entries)
132+ if err != nil {
133+ return err
134+ }
135+ hosts := make([]catalogCacheHost, 0, len(normalized))
136+ for _, entry := range normalized {
137+ target := entry.Host
138+ fetchedAt := entry.FetchedAt.UnixNano()
139+ sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
140+ for _, session := range entry.Sessions {
141+ name := session.Name
142+ state := session.State
143+ ephemeral := session.Ephemeral
144+ tabs := session.Tabs
145+ attached := session.Attached
146+ sessions = append(sessions, catalogCacheSession{
147+ Name: &name,
148+ State: &state,
149+ Ephemeral: &ephemeral,
150+ Tabs: &tabs,
151+ Attached: &attached,
152+ })
153+ }
154+ hosts = append(hosts, catalogCacheHost{
155+ Target: &target,
156+ FetchedAtUnixNano: &fetchedAt,
157+ Sessions: &sessions,
158+ })
159+ }
160+ version := catalogCacheFileVersion
161+ payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts})
162+ if err != nil {
163+ return err
164+ }
165+ return atomicReplacePrivateFile(c.path, append(payload, '\n'))
166+}
167+
168+func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
169+ normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
170+ hosts := make(map[string]struct{}, len(entries))
171+ for _, entry := range entries {
172+ if err := validateHostTarget(entry.Host); err != nil {
173+ return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
174+ }
175+ if entry.FetchedAt.UnixNano() <= 0 {
176+ return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
177+ }
178+ if _, duplicate := hosts[entry.Host]; duplicate {
179+ return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
180+ }
181+ hosts[entry.Host] = struct{}{}
182+
183+ copyEntry := ports.RemoteCatalogCacheEntry{
184+ Host: entry.Host,
185+ FetchedAt: entry.FetchedAt,
186+ Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),
187+ }
188+ sessions := make(map[string]struct{}, len(entry.Sessions))
189+ for _, session := range entry.Sessions {
190+ if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) {
191+ return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
192+ }
193+ if err := domain.ValidateSessionName(session.Name); err != nil {
194+ return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
195+ }
196+ if !validCatalogState(session.State) {
197+ return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
198+ }
199+ if _, duplicate := sessions[session.Name]; duplicate {
200+ return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
201+ }
202+ sessions[session.Name] = struct{}{}
203+ copyEntry.Sessions = append(copyEntry.Sessions, session)
204+ }
205+ sort.Slice(copyEntry.Sessions, func(i, j int) bool {
206+ return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
207+ })
208+ normalized = append(normalized, copyEntry)
209+ }
210+ sort.Slice(normalized, func(i, j int) bool {
211+ return normalized[i].Host < normalized[j].Host
212+ })
213+ return normalized, nil
214+}
@@ -0,0 +1,214 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ catalogCacheFileName = "remote-catalog-cache.json"
20+ catalogCacheFileVersion = 1
21+)
22+
23+// CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
24+func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) }
25+
26+type catalogCacheFile struct {
27+ Version *int `json:"version"`
28+ Hosts *[]catalogCacheHost `json:"hosts"`
29+}
30+
31+type catalogCacheHost struct {
32+ Target *string `json:"target"`
33+ FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
34+ Sessions *[]catalogCacheSession `json:"sessions"`
35+}
36+
37+type catalogCacheSession struct {
38+ Name *string `json:"name"`
39+ State *string `json:"state"`
40+ Ephemeral *bool `json:"ephemeral"`
41+ Tabs *uint16 `json:"tabs"`
42+ Attached *bool `json:"attached"`
43+}
44+
45+type fileCatalogCache struct {
46+ path string
47+}
48+
49+// NewFileCatalogCache returns a durable remote catalog cache backed by path.
50+func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
51+ return &fileCatalogCache{path: path}
52+}
53+
54+var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
55+
56+// Load reads and validates one complete cache snapshot. A missing cache is an
57+// empty snapshot; malformed cache data is never changed by Load.
58+func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
59+ raw, err := os.ReadFile(c.path)
60+ if errors.Is(err, os.ErrNotExist) {
61+ return []ports.RemoteCatalogCacheEntry{}, nil
62+ }
63+ if err != nil {
64+ return nil, err
65+ }
66+ if !utf8.Valid(raw) {
67+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
68+ }
69+
70+ dec := json.NewDecoder(bytes.NewReader(raw))
71+ dec.DisallowUnknownFields()
72+ var file catalogCacheFile
73+ if err := dec.Decode(&file); err != nil {
74+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
75+ }
76+ if err := rejectTrailingJSON(dec); err != nil {
77+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
78+ }
79+ if file.Version == nil {
80+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
81+ }
82+ if *file.Version != catalogCacheFileVersion {
83+ return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)
84+ }
85+ if file.Hosts == nil {
86+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
87+ }
88+
89+ entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
90+ for _, host := range *file.Hosts {
91+ if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
92+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
93+ }
94+ if !utf8.ValidString(*host.Target) {
95+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
96+ }
97+ if *host.FetchedAtUnixNano <= 0 {
98+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
99+ }
100+ entry := ports.RemoteCatalogCacheEntry{
101+ Host: *host.Target,
102+ FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
103+ Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
104+ }
105+ for _, session := range *host.Sessions {
106+ if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
107+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
108+ }
109+ if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
110+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
111+ }
112+ entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{
113+ Name: *session.Name,
114+ State: *session.State,
115+ Ephemeral: *session.Ephemeral,
116+ Tabs: *session.Tabs,
117+ Attached: *session.Attached,
118+ })
119+ }
120+ entries = append(entries, entry)
121+ }
122+ normalized, err := normalizeCatalogCacheEntries(entries)
123+ if err != nil {
124+ return nil, err
125+ }
126+ return normalized, nil
127+}
128+
129+// Store atomically replaces the cache with a complete normalized snapshot.
130+func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
131+ normalized, err := normalizeCatalogCacheEntries(entries)
132+ if err != nil {
133+ return err
134+ }
135+ hosts := make([]catalogCacheHost, 0, len(normalized))
136+ for _, entry := range normalized {
137+ target := entry.Host
138+ fetchedAt := entry.FetchedAt.UnixNano()
139+ sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
140+ for _, session := range entry.Sessions {
141+ name := session.Name
142+ state := session.State
143+ ephemeral := session.Ephemeral
144+ tabs := session.Tabs
145+ attached := session.Attached
146+ sessions = append(sessions, catalogCacheSession{
147+ Name: &name,
148+ State: &state,
149+ Ephemeral: &ephemeral,
150+ Tabs: &tabs,
151+ Attached: &attached,
152+ })
153+ }
154+ hosts = append(hosts, catalogCacheHost{
155+ Target: &target,
156+ FetchedAtUnixNano: &fetchedAt,
157+ Sessions: &sessions,
158+ })
159+ }
160+ version := catalogCacheFileVersion
161+ payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts})
162+ if err != nil {
163+ return err
164+ }
165+ return atomicReplacePrivateFile(c.path, append(payload, '\n'))
166+}
167+
168+func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
169+ normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
170+ hosts := make(map[string]struct{}, len(entries))
171+ for _, entry := range entries {
172+ if err := validateHostTarget(entry.Host); err != nil {
173+ return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
174+ }
175+ if entry.FetchedAt.UnixNano() <= 0 {
176+ return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
177+ }
178+ if _, duplicate := hosts[entry.Host]; duplicate {
179+ return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
180+ }
181+ hosts[entry.Host] = struct{}{}
182+
183+ copyEntry := ports.RemoteCatalogCacheEntry{
184+ Host: entry.Host,
185+ FetchedAt: entry.FetchedAt,
186+ Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),
187+ }
188+ sessions := make(map[string]struct{}, len(entry.Sessions))
189+ for _, session := range entry.Sessions {
190+ if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) {
191+ return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
192+ }
193+ if err := domain.ValidateSessionName(session.Name); err != nil {
194+ return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
195+ }
196+ if !validCatalogState(session.State) {
197+ return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
198+ }
199+ if _, duplicate := sessions[session.Name]; duplicate {
200+ return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
201+ }
202+ sessions[session.Name] = struct{}{}
203+ copyEntry.Sessions = append(copyEntry.Sessions, session)
204+ }
205+ sort.Slice(copyEntry.Sessions, func(i, j int) bool {
206+ return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
207+ })
208+ normalized = append(normalized, copyEntry)
209+ }
210+ sort.Slice(normalized, func(i, j int) bool {
211+ return normalized[i].Host < normalized[j].Host
212+ })
213+ return normalized, nil
214+}
A internal/adapters/remote/catalog_cache_test.go +272 -0
@@ -0,0 +1,272 @@
1+package remote
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestCatalogCachePath(t *testing.T) {
15+ stateDir := filepath.Join(t.TempDir(), "state")
16+ want := filepath.Join(stateDir, "remote-catalog-cache.json")
17+ if got := CatalogCachePath(stateDir); got != want {
18+ t.Fatalf("CatalogCachePath(%q) = %q, want %q", stateDir, got, want)
19+ }
20+}
21+
22+func TestCatalogCacheStoreLoad(t *testing.T) {
23+ t.Parallel()
24+
25+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
26+ cache := NewFileCatalogCache(path)
27+ fetchedAt := time.Unix(0, 1780000000000000000)
28+ entries := []ports.RemoteCatalogCacheEntry{
29+ {
30+ Host: "zebra",
31+ FetchedAt: fetchedAt,
32+ Sessions: []ports.RemoteCatalogSession{
33+ {Name: "work", State: "running", Tabs: 2},
34+ {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
35+ },
36+ },
37+ {
38+ Host: "arch",
39+ FetchedAt: fetchedAt.Add(time.Second),
40+ Sessions: []ports.RemoteCatalogSession{},
41+ },
42+ }
43+
44+ if err := cache.Store(entries); err != nil {
45+ t.Fatalf("Store() error = %v", err)
46+ }
47+ raw, err := os.ReadFile(path)
48+ if err != nil {
49+ t.Fatalf("read cache: %v", err)
50+ }
51+ arch := strings.Index(string(raw), `"target":"arch"`)
52+ zebra := strings.Index(string(raw), `"target":"zebra"`)
53+ if arch < 0 || zebra < 0 || arch >= zebra {
54+ t.Fatalf("stored hosts are not ordered: %s", raw)
55+ }
56+ alpha := strings.Index(string(raw), `"name":"alpha"`)
57+ work := strings.Index(string(raw), `"name":"work"`)
58+ if alpha < 0 || work < 0 || alpha >= work {
59+ t.Fatalf("stored sessions are not ordered: %s", raw)
60+ }
61+ got, err := cache.Load()
62+ if err != nil {
63+ t.Fatalf("Load() error = %v", err)
64+ }
65+ want := []ports.RemoteCatalogCacheEntry{
66+ {
67+ Host: "arch",
68+ FetchedAt: fetchedAt.Add(time.Second),
69+ Sessions: []ports.RemoteCatalogSession{},
70+ },
71+ {
72+ Host: "zebra",
73+ FetchedAt: fetchedAt,
74+ Sessions: []ports.RemoteCatalogSession{
75+ {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
76+ {Name: "work", State: "running", Tabs: 2},
77+ },
78+ },
79+ }
80+ if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
81+ t.Fatalf("Load() = %#v, want %#v", got, want)
82+ }
83+
84+ info, err := os.Stat(path)
85+ if err != nil {
86+ t.Fatalf("stat cache: %v", err)
87+ }
88+ if perm := info.Mode().Perm(); perm != 0o600 {
89+ t.Fatalf("cache permissions = %04o, want 0600", perm)
90+ }
91+ dirInfo, err := os.Lstat(filepath.Dir(path))
92+ if err != nil {
93+ t.Fatalf("stat cache directory: %v", err)
94+ }
95+ if perm := dirInfo.Mode().Perm(); perm != 0o700 {
96+ t.Fatalf("cache directory permissions = %04o, want 0700", perm)
97+ }
98+ entriesOnDisk, err := os.ReadDir(filepath.Dir(path))
99+ if err != nil {
100+ t.Fatalf("read cache directory: %v", err)
101+ }
102+ for _, entry := range entriesOnDisk {
103+ if strings.Contains(entry.Name(), ".tmp") {
104+ t.Fatalf("temporary file left behind: %q", entry.Name())
105+ }
106+ }
107+}
108+
109+func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
110+ t.Parallel()
111+
112+ cases := []struct {
113+ name string
114+ raw []byte
115+ }{
116+ {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},
117+ {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},
118+ {name: "unknown version", raw: []byte(`{"version":2,"hosts":[]}`)},
119+ {name: "missing hosts", raw: []byte(`{"version":1}`)},
120+ {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},
121+ {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
122+ {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123+ {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124+ {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125+ {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
126+ {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ t.Parallel()
131+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
132+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
133+ t.Fatalf("mkdir: %v", err)
134+ }
135+ if err := os.WriteFile(path, tc.raw, 0o600); err != nil {
136+ t.Fatalf("write cache: %v", err)
137+ }
138+
139+ if _, err := NewFileCatalogCache(path).Load(); err == nil {
140+ t.Fatal("Load() error = nil, want malformed cache error")
141+ }
142+ after, err := os.ReadFile(path)
143+ if err != nil {
144+ t.Fatalf("read cache: %v", err)
145+ }
146+ if !slices.Equal(after, tc.raw) {
147+ t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw)
148+ }
149+ })
150+ }
151+}
152+
153+func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
154+ t.Parallel()
155+
156+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
157+ cache := NewFileCatalogCache(path)
158+ valid := []ports.RemoteCatalogCacheEntry{{
159+ Host: "arch",
160+ FetchedAt: time.Unix(0, 1),
161+ Sessions: []ports.RemoteCatalogSession{},
162+ }}
163+ if err := cache.Store(valid); err != nil {
164+ t.Fatalf("initial Store() error = %v", err)
165+ }
166+ before, err := os.ReadFile(path)
167+ if err != nil {
168+ t.Fatalf("read initial cache: %v", err)
169+ }
170+
171+ invalid := []ports.RemoteCatalogCacheEntry{{
172+ Host: "arch",
173+ FetchedAt: time.Time{},
174+ Sessions: []ports.RemoteCatalogSession{},
175+ }}
176+ if err := cache.Store(invalid); err == nil {
177+ t.Fatal("Store() error = nil, want validation error")
178+ }
179+ after, err := os.ReadFile(path)
180+ if err != nil {
181+ t.Fatalf("read cache after failed Store: %v", err)
182+ }
183+ if !slices.Equal(after, before) {
184+ t.Fatalf("failed Store changed cache: got %q, want %q", after, before)
185+ }
186+}
187+
188+func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
189+ t.Parallel()
190+
191+ for _, tc := range []struct {
192+ name string
193+ fetchedAt time.Time
194+ }{
195+ {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)},
196+ {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)},
197+ } {
198+ t.Run(tc.name, func(t *testing.T) {
199+ t.Parallel()
200+ path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
201+ entries := []ports.RemoteCatalogCacheEntry{{
202+ Host: "arch",
203+ FetchedAt: tc.fetchedAt,
204+ Sessions: []ports.RemoteCatalogSession{},
205+ }}
206+
207+ err := NewFileCatalogCache(path).Store(entries)
208+
209+ if err == nil {
210+ t.Fatal("Store() error = nil, want non-positive fetched time error")
211+ }
212+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
213+ t.Fatalf("failed Store created cache: stat error = %v", statErr)
214+ }
215+ })
216+ }
217+}
218+
219+func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
220+ t.Parallel()
221+
222+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
223+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
224+ t.Fatalf("mkdir: %v", err)
225+ }
226+ if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil {
227+ t.Fatalf("seed cache: %v", err)
228+ }
229+
230+ cache := NewFileCatalogCache(path)
231+ if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil {
232+ t.Fatalf("Store() error = %v", err)
233+ }
234+ info, err := os.Stat(path)
235+ if err != nil {
236+ t.Fatalf("stat cache: %v", err)
237+ }
238+ if perm := info.Mode().Perm(); perm != 0o600 {
239+ t.Fatalf("cache permissions = %04o, want 0600", perm)
240+ }
241+}
242+
243+func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
244+ t.Parallel()
245+
246+ dir := filepath.Join(t.TempDir(), "vev")
247+ if err := os.MkdirAll(dir, 0o700); err != nil {
248+ t.Fatalf("mkdir parent: %v", err)
249+ }
250+ path := filepath.Join(dir, "remote-catalog-cache.json")
251+ if err := os.Mkdir(path, 0o700); err != nil {
252+ t.Fatalf("mkdir destination: %v", err)
253+ }
254+
255+ cache := NewFileCatalogCache(path)
256+ if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil {
257+ t.Fatal("Store() error = nil, want rename error")
258+ }
259+ entries, err := os.ReadDir(dir)
260+ if err != nil {
261+ t.Fatalf("read cache directory: %v", err)
262+ }
263+ for _, entry := range entries {
264+ if strings.Contains(entry.Name(), ".tmp") {
265+ t.Fatalf("temporary file left behind after failed rename: %q", entry.Name())
266+ }
267+ }
268+}
269+
270+func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)
272+}
@@ -0,0 +1,272 @@
1+package remote
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestCatalogCachePath(t *testing.T) {
15+ stateDir := filepath.Join(t.TempDir(), "state")
16+ want := filepath.Join(stateDir, "remote-catalog-cache.json")
17+ if got := CatalogCachePath(stateDir); got != want {
18+ t.Fatalf("CatalogCachePath(%q) = %q, want %q", stateDir, got, want)
19+ }
20+}
21+
22+func TestCatalogCacheStoreLoad(t *testing.T) {
23+ t.Parallel()
24+
25+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
26+ cache := NewFileCatalogCache(path)
27+ fetchedAt := time.Unix(0, 1780000000000000000)
28+ entries := []ports.RemoteCatalogCacheEntry{
29+ {
30+ Host: "zebra",
31+ FetchedAt: fetchedAt,
32+ Sessions: []ports.RemoteCatalogSession{
33+ {Name: "work", State: "running", Tabs: 2},
34+ {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
35+ },
36+ },
37+ {
38+ Host: "arch",
39+ FetchedAt: fetchedAt.Add(time.Second),
40+ Sessions: []ports.RemoteCatalogSession{},
41+ },
42+ }
43+
44+ if err := cache.Store(entries); err != nil {
45+ t.Fatalf("Store() error = %v", err)
46+ }
47+ raw, err := os.ReadFile(path)
48+ if err != nil {
49+ t.Fatalf("read cache: %v", err)
50+ }
51+ arch := strings.Index(string(raw), `"target":"arch"`)
52+ zebra := strings.Index(string(raw), `"target":"zebra"`)
53+ if arch < 0 || zebra < 0 || arch >= zebra {
54+ t.Fatalf("stored hosts are not ordered: %s", raw)
55+ }
56+ alpha := strings.Index(string(raw), `"name":"alpha"`)
57+ work := strings.Index(string(raw), `"name":"work"`)
58+ if alpha < 0 || work < 0 || alpha >= work {
59+ t.Fatalf("stored sessions are not ordered: %s", raw)
60+ }
61+ got, err := cache.Load()
62+ if err != nil {
63+ t.Fatalf("Load() error = %v", err)
64+ }
65+ want := []ports.RemoteCatalogCacheEntry{
66+ {
67+ Host: "arch",
68+ FetchedAt: fetchedAt.Add(time.Second),
69+ Sessions: []ports.RemoteCatalogSession{},
70+ },
71+ {
72+ Host: "zebra",
73+ FetchedAt: fetchedAt,
74+ Sessions: []ports.RemoteCatalogSession{
75+ {Name: "alpha", State: "stopped", Ephemeral: true, Tabs: 1, Attached: true},
76+ {Name: "work", State: "running", Tabs: 2},
77+ },
78+ },
79+ }
80+ if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
81+ t.Fatalf("Load() = %#v, want %#v", got, want)
82+ }
83+
84+ info, err := os.Stat(path)
85+ if err != nil {
86+ t.Fatalf("stat cache: %v", err)
87+ }
88+ if perm := info.Mode().Perm(); perm != 0o600 {
89+ t.Fatalf("cache permissions = %04o, want 0600", perm)
90+ }
91+ dirInfo, err := os.Lstat(filepath.Dir(path))
92+ if err != nil {
93+ t.Fatalf("stat cache directory: %v", err)
94+ }
95+ if perm := dirInfo.Mode().Perm(); perm != 0o700 {
96+ t.Fatalf("cache directory permissions = %04o, want 0700", perm)
97+ }
98+ entriesOnDisk, err := os.ReadDir(filepath.Dir(path))
99+ if err != nil {
100+ t.Fatalf("read cache directory: %v", err)
101+ }
102+ for _, entry := range entriesOnDisk {
103+ if strings.Contains(entry.Name(), ".tmp") {
104+ t.Fatalf("temporary file left behind: %q", entry.Name())
105+ }
106+ }
107+}
108+
109+func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
110+ t.Parallel()
111+
112+ cases := []struct {
113+ name string
114+ raw []byte
115+ }{
116+ {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},
117+ {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},
118+ {name: "unknown version", raw: []byte(`{"version":2,"hosts":[]}`)},
119+ {name: "missing hosts", raw: []byte(`{"version":1}`)},
120+ {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},
121+ {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
122+ {name: "zero fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
123+ {name: "negative fetched at", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
124+ {name: "duplicate hosts", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
125+ {name: "duplicate sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false},{"name":"work","state":"running","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
126+ {name: "invalid utf8", raw: []byte("{\"version\":1,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
127+ }
128+ for _, tc := range cases {
129+ t.Run(tc.name, func(t *testing.T) {
130+ t.Parallel()
131+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
132+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
133+ t.Fatalf("mkdir: %v", err)
134+ }
135+ if err := os.WriteFile(path, tc.raw, 0o600); err != nil {
136+ t.Fatalf("write cache: %v", err)
137+ }
138+
139+ if _, err := NewFileCatalogCache(path).Load(); err == nil {
140+ t.Fatal("Load() error = nil, want malformed cache error")
141+ }
142+ after, err := os.ReadFile(path)
143+ if err != nil {
144+ t.Fatalf("read cache: %v", err)
145+ }
146+ if !slices.Equal(after, tc.raw) {
147+ t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw)
148+ }
149+ })
150+ }
151+}
152+
153+func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
154+ t.Parallel()
155+
156+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
157+ cache := NewFileCatalogCache(path)
158+ valid := []ports.RemoteCatalogCacheEntry{{
159+ Host: "arch",
160+ FetchedAt: time.Unix(0, 1),
161+ Sessions: []ports.RemoteCatalogSession{},
162+ }}
163+ if err := cache.Store(valid); err != nil {
164+ t.Fatalf("initial Store() error = %v", err)
165+ }
166+ before, err := os.ReadFile(path)
167+ if err != nil {
168+ t.Fatalf("read initial cache: %v", err)
169+ }
170+
171+ invalid := []ports.RemoteCatalogCacheEntry{{
172+ Host: "arch",
173+ FetchedAt: time.Time{},
174+ Sessions: []ports.RemoteCatalogSession{},
175+ }}
176+ if err := cache.Store(invalid); err == nil {
177+ t.Fatal("Store() error = nil, want validation error")
178+ }
179+ after, err := os.ReadFile(path)
180+ if err != nil {
181+ t.Fatalf("read cache after failed Store: %v", err)
182+ }
183+ if !slices.Equal(after, before) {
184+ t.Fatalf("failed Store changed cache: got %q, want %q", after, before)
185+ }
186+}
187+
188+func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
189+ t.Parallel()
190+
191+ for _, tc := range []struct {
192+ name string
193+ fetchedAt time.Time
194+ }{
195+ {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)},
196+ {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)},
197+ } {
198+ t.Run(tc.name, func(t *testing.T) {
199+ t.Parallel()
200+ path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
201+ entries := []ports.RemoteCatalogCacheEntry{{
202+ Host: "arch",
203+ FetchedAt: tc.fetchedAt,
204+ Sessions: []ports.RemoteCatalogSession{},
205+ }}
206+
207+ err := NewFileCatalogCache(path).Store(entries)
208+
209+ if err == nil {
210+ t.Fatal("Store() error = nil, want non-positive fetched time error")
211+ }
212+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
213+ t.Fatalf("failed Store created cache: stat error = %v", statErr)
214+ }
215+ })
216+ }
217+}
218+
219+func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
220+ t.Parallel()
221+
222+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
223+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
224+ t.Fatalf("mkdir: %v", err)
225+ }
226+ if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil {
227+ t.Fatalf("seed cache: %v", err)
228+ }
229+
230+ cache := NewFileCatalogCache(path)
231+ if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err != nil {
232+ t.Fatalf("Store() error = %v", err)
233+ }
234+ info, err := os.Stat(path)
235+ if err != nil {
236+ t.Fatalf("stat cache: %v", err)
237+ }
238+ if perm := info.Mode().Perm(); perm != 0o600 {
239+ t.Fatalf("cache permissions = %04o, want 0600", perm)
240+ }
241+}
242+
243+func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
244+ t.Parallel()
245+
246+ dir := filepath.Join(t.TempDir(), "vev")
247+ if err := os.MkdirAll(dir, 0o700); err != nil {
248+ t.Fatalf("mkdir parent: %v", err)
249+ }
250+ path := filepath.Join(dir, "remote-catalog-cache.json")
251+ if err := os.Mkdir(path, 0o700); err != nil {
252+ t.Fatalf("mkdir destination: %v", err)
253+ }
254+
255+ cache := NewFileCatalogCache(path)
256+ if err := cache.Store([]ports.RemoteCatalogCacheEntry{}); err == nil {
257+ t.Fatal("Store() error = nil, want rename error")
258+ }
259+ entries, err := os.ReadDir(dir)
260+ if err != nil {
261+ t.Fatalf("read cache directory: %v", err)
262+ }
263+ for _, entry := range entries {
264+ if strings.Contains(entry.Name(), ".tmp") {
265+ t.Fatalf("temporary file left behind after failed rename: %q", entry.Name())
266+ }
267+ }
268+}
269+
270+func equalRemoteCatalogCacheEntry(a, b ports.RemoteCatalogCacheEntry) bool {
271+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && slices.Equal(a.Sessions, b.Sessions)
272+}
M internal/adapters/remote/host_registry.go +16 -5
@@ -17,7 +17,13 @@ import (
1717 "github.com/bnema/vev/pkg/safedir"
1818 )
1919
20-const hostsFileVersion = 2
20+const (
21+ hostsFileName = "hosts.json"
22+ hostsFileVersion = 2
23+)
24+
25+// HostStorePath returns the canonical location of the remote host store in stateDir.
26+func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
2127
2228 type hostsFile struct {
2329 Version int `json:"version"`
@@ -273,10 +279,15 @@ func (s *fileHostStore) saveLocked(state hostState) error {
273279 if err != nil {
274280 return err
275281 }
276- payload = append(payload, '\n')
282+ return atomicReplacePrivateFile(s.path, append(payload, '\n'))
283+}
277284
278- dir := filepath.Dir(s.path)
279- tmp, err := os.CreateTemp(dir, ".hosts-*.tmp")
285+func 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")
280291 if err != nil {
281292 return err
282293 }
@@ -300,7 +311,7 @@ func (s *fileHostStore) saveLocked(state hostState) error {
300311 if err := tmp.Close(); err != nil {
301312 return err
302313 }
303- if err := os.Rename(tmpName, s.path); err != nil {
314+ if err := os.Rename(tmpName, path); err != nil {
304315 return err
305316 }
306317 committed = true
@@ -17,7 +17,13 @@ import (
17 "github.com/bnema/vev/pkg/safedir"17 "github.com/bnema/vev/pkg/safedir"
18 )18 )
19 19
20-const hostsFileVersion = 220+const (
21+ hostsFileName = "hosts.json"
22+ hostsFileVersion = 2
23+)
24+
25+// HostStorePath returns the canonical location of the remote host store in stateDir.
26+func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
21 27
22 type hostsFile struct {28 type hostsFile struct {
23 Version int `json:"version"`29 Version int `json:"version"`
@@ -273,10 +279,15 @@ func (s *fileHostStore) saveLocked(state hostState) error {
273 if err != nil {279 if err != nil {
274 return err280 return err
275 }281 }
276- payload = append(payload, '\n')282+ return atomicReplacePrivateFile(s.path, append(payload, '\n'))
283+}
277 284
278- dir := filepath.Dir(s.path)285+func atomicReplacePrivateFile(path string, payload []byte) error {
279- tmp, err := os.CreateTemp(dir, ".hosts-*.tmp")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")
280 if err != nil {291 if err != nil {
281 return err292 return err
282 }293 }
@@ -300,7 +311,7 @@ func (s *fileHostStore) saveLocked(state hostState) error {
300 if err := tmp.Close(); err != nil {311 if err := tmp.Close(); err != nil {
301 return err312 return err
302 }313 }
303- if err := os.Rename(tmpName, s.path); err != nil {314+ if err := os.Rename(tmpName, path); err != nil {
304 return err315 return err
305 }316 }
306 committed = true317 committed = true
M internal/adapters/remote/host_registry_test.go +19 -1
@@ -13,6 +13,14 @@ import (
1313 "testing"
1414 )
1515
16+func TestHostStorePath(t *testing.T) {
17+ stateDir := filepath.Join(t.TempDir(), "state")
18+ want := filepath.Join(stateDir, "hosts.json")
19+ if got := HostStorePath(stateDir); got != want {
20+ t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want)
21+ }
22+}
23+
1624 func TestHostStoreCrossProcessHelper(t *testing.T) {
1725 if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
1826 t.Skip("helper process")
@@ -515,13 +523,16 @@ func TestHostStore(t *testing.T) {
515523 }
516524 })
517525
518- t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) {
526+ t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) {
519527 t.Parallel()
520528 path := storePath(t)
521529 dir := filepath.Dir(path)
522530 if err := os.MkdirAll(dir, 0o700); err != nil {
523531 t.Fatalf("mkdir: %v", err)
524532 }
533+ if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil {
534+ t.Fatalf("seed hosts: %v", err)
535+ }
525536 lockPath := path + ".lock"
526537 if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
527538 t.Fatalf("seed lock: %v", err)
@@ -539,6 +550,13 @@ func TestHostStore(t *testing.T) {
539550 t.Fatalf("Remember() error = %v", err)
540551 }
541552
553+ info, err = os.Stat(path)
554+ if err != nil {
555+ t.Fatalf("stat hosts: %v", err)
556+ }
557+ if perm := info.Mode().Perm(); perm != 0o600 {
558+ t.Fatalf("hosts permissions = %04o, want 0600", perm)
559+ }
542560 info, err = os.Stat(lockPath)
543561 if err != nil {
544562 t.Fatalf("stat lock: %v", err)
@@ -13,6 +13,14 @@ import (
13 "testing"13 "testing"
14 )14 )
15 15
16+func TestHostStorePath(t *testing.T) {
17+ stateDir := filepath.Join(t.TempDir(), "state")
18+ want := filepath.Join(stateDir, "hosts.json")
19+ if got := HostStorePath(stateDir); got != want {
20+ t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want)
21+ }
22+}
23+
16 func TestHostStoreCrossProcessHelper(t *testing.T) {24 func TestHostStoreCrossProcessHelper(t *testing.T) {
17 if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {25 if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
18 t.Skip("helper process")26 t.Skip("helper process")
@@ -515,13 +523,16 @@ func TestHostStore(t *testing.T) {
515 }523 }
516 })524 })
517 525
518- t.Run("hardens existing lock file permissions to 0600", func(t *testing.T) {526+ t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) {
519 t.Parallel()527 t.Parallel()
520 path := storePath(t)528 path := storePath(t)
521 dir := filepath.Dir(path)529 dir := filepath.Dir(path)
522 if err := os.MkdirAll(dir, 0o700); err != nil {530 if err := os.MkdirAll(dir, 0o700); err != nil {
523 t.Fatalf("mkdir: %v", err)531 t.Fatalf("mkdir: %v", err)
524 }532 }
533+ if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil {
534+ t.Fatalf("seed hosts: %v", err)
535+ }
525 lockPath := path + ".lock"536 lockPath := path + ".lock"
526 if err := os.WriteFile(lockPath, nil, 0o644); err != nil {537 if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
527 t.Fatalf("seed lock: %v", err)538 t.Fatalf("seed lock: %v", err)
@@ -539,6 +550,13 @@ func TestHostStore(t *testing.T) {
539 t.Fatalf("Remember() error = %v", err)550 t.Fatalf("Remember() error = %v", err)
540 }551 }
541 552
553+ info, err = os.Stat(path)
554+ if err != nil {
555+ t.Fatalf("stat hosts: %v", err)
556+ }
557+ if perm := info.Mode().Perm(); perm != 0o600 {
558+ t.Fatalf("hosts permissions = %04o, want 0600", perm)
559+ }
542 info, err = os.Stat(lockPath)560 info, err = os.Stat(lockPath)
543 if err != nil {561 if err != nil {
544 t.Fatalf("stat lock: %v", err)562 t.Fatalf("stat lock: %v", err)
M internal/app/remote_hosts.go +2 -3
@@ -7,7 +7,6 @@ import (
77 "io"
88 "log/slog"
99 "os"
10- "path/filepath"
1110 "sort"
1211 "text/tabwriter"
1312
@@ -46,7 +45,7 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
4645 d.stateDir = platform.StateDir
4746 }
4847 if d.store == nil {
49- d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json"))
48+ d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir()))
5049 }
5150 if d.catalog == nil {
5251 d.catalog = remoteadapter.NewCatalogClient()
@@ -277,7 +276,7 @@ func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logg
277276 if stateDir == nil {
278277 stateDir = platform.StateDir
279278 }
280- store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json"))
279+ store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir()))
281280 }
282281 return remoteHostLearner{store: store, target: remoteTarget}
283282 }
@@ -7,7 +7,6 @@ import (
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9 "os"9 "os"
10- "path/filepath"
11 "sort"10 "sort"
12 "text/tabwriter"11 "text/tabwriter"
13 12
@@ -46,7 +45,7 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
46 d.stateDir = platform.StateDir45 d.stateDir = platform.StateDir
47 }46 }
48 if d.store == nil {47 if d.store == nil {
49- d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json"))48+ d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir()))
50 }49 }
51 if d.catalog == nil {50 if d.catalog == nil {
52 d.catalog = remoteadapter.NewCatalogClient()51 d.catalog = remoteadapter.NewCatalogClient()
@@ -277,7 +276,7 @@ func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logg
277 if stateDir == nil {276 if stateDir == nil {
278 stateDir = platform.StateDir277 stateDir = platform.StateDir
279 }278 }
280- store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json"))279+ store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir()))
281 }280 }
282 return remoteHostLearner{store: store, target: remoteTarget}281 return remoteHostLearner{store: store, target: remoteTarget}
283 }282 }
M internal/app/run.go +24 -1
@@ -343,6 +343,9 @@ func parseHostArgs(args []string) (command, error) {
343343 // opening real transports.
344344 var (
345345 newPerformanceTrace = performanceTrace
346+ newRemoteHostStore = remoteadapter.NewFileHostStore
347+ newRemoteCatalogCache = remoteadapter.NewFileCatalogCache
348+ newRemoteCatalogClient = remoteadapter.NewCatalogClient
346349 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
347350 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
348351 }
@@ -610,6 +613,10 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
610613 if observerCloser != nil {
611614 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
612615 }
616+ remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport))
617+ if err != nil {
618+ return err
619+ }
613620 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
614621 if !pprofAddrIsLoopback(addr) {
615622 log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
@@ -622,7 +629,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
622629 log.Info("pprof enabled", "addr", addr)
623630 }
624631
625- daemonOpts := []daemon.Option(nil)
632+ daemonOpts := []daemon.Option{remoteDiscoveryOpt}
626633 if observer != nil {
627634 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
628635 }
@@ -781,6 +788,22 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error)
781788 }
782789 }
783790
791+// remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
792+// the same validated transport selection used by direct remote attach.
793+func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {
794+ mode, err := remoteTransportModeFromEnv(transport)
795+ if err != nil {
796+ return nil, err
797+ }
798+ return daemon.WithRemoteDiscovery(
799+ newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
800+ newRemoteCatalogClient(),
801+ newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
802+ newRemoteDialerFactoryWithRuntimeObserver(observer),
803+ mode,
804+ ), nil
805+}
806+
784807 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
785808 if activeSession != "" {
786809 if remoteTarget == "" && intent == ports.IntentNew {
@@ -343,6 +343,9 @@ func parseHostArgs(args []string) (command, error) {
343 // opening real transports.343 // opening real transports.
344 var (344 var (
345 newPerformanceTrace = performanceTrace345 newPerformanceTrace = performanceTrace
346+ newRemoteHostStore = remoteadapter.NewFileHostStore
347+ newRemoteCatalogCache = remoteadapter.NewFileCatalogCache
348+ newRemoteCatalogClient = remoteadapter.NewCatalogClient
346 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {349 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
347 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)350 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
348 }351 }
@@ -610,6 +613,10 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
610 if observerCloser != nil {613 if observerCloser != nil {
611 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()614 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
612 }615 }
616+ remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), observer, os.Getenv(envRemoteTransport))
617+ if err != nil {
618+ return err
619+ }
613 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {620 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
614 if !pprofAddrIsLoopback(addr) {621 if !pprofAddrIsLoopback(addr) {
615 log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)622 log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
@@ -622,7 +629,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
622 log.Info("pprof enabled", "addr", addr)629 log.Info("pprof enabled", "addr", addr)
623 }630 }
624 631
625- daemonOpts := []daemon.Option(nil)632+ daemonOpts := []daemon.Option{remoteDiscoveryOpt}
626 if observer != nil {633 if observer != nil {
627 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))634 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
628 }635 }
@@ -781,6 +788,22 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error)
781 }788 }
782 }789 }
783 790
791+// remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
792+// the same validated transport selection used by direct remote attach.
793+func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {
794+ mode, err := remoteTransportModeFromEnv(transport)
795+ if err != nil {
796+ return nil, err
797+ }
798+ return daemon.WithRemoteDiscovery(
799+ newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
800+ newRemoteCatalogClient(),
801+ newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
802+ newRemoteDialerFactoryWithRuntimeObserver(observer),
803+ mode,
804+ ), nil
805+}
806+
784 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {807 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
785 if activeSession != "" {808 if activeSession != "" {
786 if remoteTarget == "" && intent == ports.IntentNew {809 if remoteTarget == "" && intent == ports.IntentNew {
M internal/app/run_test.go +51 -0
@@ -17,6 +17,7 @@ import (
1717 "time"
1818
1919 "github.com/bnema/vev/internal/adapters/lifecycle"
20+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
2021 "github.com/bnema/vev/internal/domain"
2122 "github.com/bnema/vev/internal/persist"
2223 "github.com/bnema/vev/internal/ports"
@@ -1359,3 +1360,53 @@ func TestPprofAddrIsLoopback(t *testing.T) {
13591360 })
13601361 }
13611362 }
1363+
1364+type appCatalogCacheStub struct {
1365+ loads int
1366+}
1367+
1368+func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
1369+ s.loads++
1370+ return nil, nil
1371+}
1372+
1373+func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }
1374+
1375+func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1376+ oldHostStore := newRemoteHostStore
1377+ oldCatalogCache := newRemoteCatalogCache
1378+ oldCatalogClient := newRemoteCatalogClient
1379+ oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver
1380+ t.Cleanup(func() {
1381+ newRemoteHostStore = oldHostStore
1382+ newRemoteCatalogCache = oldCatalogCache
1383+ newRemoteCatalogClient = oldCatalogClient
1384+ newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory
1385+ })
1386+
1387+ stateDir := t.TempDir()
1388+ cache := &appCatalogCacheStub{}
1389+ var hostPath, cachePath string
1390+ newRemoteHostStore = func(path string) ports.RemoteHostStore {
1391+ hostPath = path
1392+ return nil
1393+ }
1394+ newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache {
1395+ cachePath = path
1396+ return cache
1397+ }
1398+ newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1399+ newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }
1400+
1401+ option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")
1402+ require.NoError(t, err)
1403+ _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
1404+ require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
1405+ require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath)
1406+ require.Equal(t, 1, cache.loads, "daemon startup must load the cache once")
1407+}
1408+
1409+func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1410+ _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")
1411+ require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
1412+}
@@ -17,6 +17,7 @@ import (
17 "time"17 "time"
18 18
19 "github.com/bnema/vev/internal/adapters/lifecycle"19 "github.com/bnema/vev/internal/adapters/lifecycle"
20+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
20 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
21 "github.com/bnema/vev/internal/persist"22 "github.com/bnema/vev/internal/persist"
22 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
@@ -1359,3 +1360,53 @@ func TestPprofAddrIsLoopback(t *testing.T) {
1359 })1360 })
1360 }1361 }
1361 }1362 }
1363+
1364+type appCatalogCacheStub struct {
1365+ loads int
1366+}
1367+
1368+func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
1369+ s.loads++
1370+ return nil, nil
1371+}
1372+
1373+func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }
1374+
1375+func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1376+ oldHostStore := newRemoteHostStore
1377+ oldCatalogCache := newRemoteCatalogCache
1378+ oldCatalogClient := newRemoteCatalogClient
1379+ oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver
1380+ t.Cleanup(func() {
1381+ newRemoteHostStore = oldHostStore
1382+ newRemoteCatalogCache = oldCatalogCache
1383+ newRemoteCatalogClient = oldCatalogClient
1384+ newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory
1385+ })
1386+
1387+ stateDir := t.TempDir()
1388+ cache := &appCatalogCacheStub{}
1389+ var hostPath, cachePath string
1390+ newRemoteHostStore = func(path string) ports.RemoteHostStore {
1391+ hostPath = path
1392+ return nil
1393+ }
1394+ newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache {
1395+ cachePath = path
1396+ return cache
1397+ }
1398+ newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1399+ newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }
1400+
1401+ option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")
1402+ require.NoError(t, err)
1403+ _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
1404+ require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
1405+ require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath)
1406+ require.Equal(t, 1, cache.loads, "daemon startup must load the cache once")
1407+}
1408+
1409+func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1410+ _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")
1411+ require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
1412+}
A internal/domain/remote_session.go +31 -0
@@ -0,0 +1,31 @@
1+package domain
2+
3+import "encoding/base64"
4+
5+// RemoteSessionKey identifies a discovered remote session independently from
6+// its display label.
7+type RemoteSessionKey struct {
8+ Host string
9+ Name string
10+}
11+
12+// Validate reports whether the host target and session name are both valid.
13+func (k RemoteSessionKey) Validate() error {
14+ if err := ValidateRemoteHostTarget(k.Host); err != nil {
15+ return err
16+ }
17+ return ValidateSessionName(k.Name)
18+}
19+
20+// ID returns the opaque, delimiter-safe ID for this remote session. Routing
21+// retains the structured key; callers must not recover it by decoding this ID.
22+func (k RemoteSessionKey) ID() SessionID {
23+ host := base64.RawURLEncoding.EncodeToString([]byte(k.Host))
24+ name := base64.RawURLEncoding.EncodeToString([]byte(k.Name))
25+ return SessionID("remote:" + host + "." + name)
26+}
27+
28+// Display returns the presentation label for this remote session.
29+func (k RemoteSessionKey) Display() string {
30+ return k.Name + "@" + k.Host
31+}
@@ -0,0 +1,31 @@
1+package domain
2+
3+import "encoding/base64"
4+
5+// RemoteSessionKey identifies a discovered remote session independently from
6+// its display label.
7+type RemoteSessionKey struct {
8+ Host string
9+ Name string
10+}
11+
12+// Validate reports whether the host target and session name are both valid.
13+func (k RemoteSessionKey) Validate() error {
14+ if err := ValidateRemoteHostTarget(k.Host); err != nil {
15+ return err
16+ }
17+ return ValidateSessionName(k.Name)
18+}
19+
20+// ID returns the opaque, delimiter-safe ID for this remote session. Routing
21+// retains the structured key; callers must not recover it by decoding this ID.
22+func (k RemoteSessionKey) ID() SessionID {
23+ host := base64.RawURLEncoding.EncodeToString([]byte(k.Host))
24+ name := base64.RawURLEncoding.EncodeToString([]byte(k.Name))
25+ return SessionID("remote:" + host + "." + name)
26+}
27+
28+// Display returns the presentation label for this remote session.
29+func (k RemoteSessionKey) Display() string {
30+ return k.Name + "@" + k.Host
31+}
A internal/domain/remote_session_test.go +100 -0
@@ -0,0 +1,100 @@
1+package domain
2+
3+import "testing"
4+
5+func TestRemoteSessionKeyID(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ key RemoteSessionKey
9+ want SessionID
10+ }{
11+ {
12+ name: "uses raw URL base64 components",
13+ key: RemoteSessionKey{Host: "arch", Name: "hello"},
14+ want: "remote:YXJjaA.aGVsbG8",
15+ },
16+ {
17+ name: "preserves host metacharacters without delimiters",
18+ key: RemoteSessionKey{Host: "build@mule:2222/path.[x]", Name: "work.1"},
19+ want: "remote:YnVpbGRAbXVsZToyMjIyL3BhdGguW3hd.d29yay4x",
20+ },
21+ {
22+ name: "supports bracketed IPv6 hosts",
23+ key: RemoteSessionKey{Host: "user@[2001:db8::1]:2222", Name: "work"},
24+ want: "remote:dXNlckBbMjAwMTpkYjg6OjFdOjIyMjI.d29yaw",
25+ },
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if got := tt.key.ID(); got != tt.want {
31+ t.Fatalf("RemoteSessionKey.ID() = %q, want %q", got, tt.want)
32+ }
33+ if got := tt.key.ID(); got != tt.key.ID() {
34+ t.Fatalf("RemoteSessionKey.ID() is unstable: %q != %q", got, tt.key.ID())
35+ }
36+ })
37+ }
38+}
39+
40+func TestRemoteSessionKeyIDAvoidsRawConcatenationCollisions(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ first RemoteSessionKey
44+ last RemoteSessionKey
45+ }{
46+ {
47+ name: "host component delimiter",
48+ first: RemoteSessionKey{Host: "arch.work", Name: "shell"},
49+ last: RemoteSessionKey{Host: "arch", Name: "work.shell"},
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ if tt.first.Host+"."+tt.first.Name != tt.last.Host+"."+tt.last.Name {
56+ t.Fatal("test setup does not collide under raw concatenation")
57+ }
58+ if tt.first.ID() == tt.last.ID() {
59+ t.Fatalf("distinct keys have the same ID: %q", tt.first.ID())
60+ }
61+ })
62+ }
63+}
64+
65+func TestRemoteSessionKeyValidate(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ key RemoteSessionKey
69+ wantErr bool
70+ }{
71+ {name: "valid", key: RemoteSessionKey{Host: "build@mule:2222/path", Name: "work.1"}},
72+ {name: "empty host", key: RemoteSessionKey{Name: "work"}, wantErr: true},
73+ {name: "host whitespace", key: RemoteSessionKey{Host: "build mule", Name: "work"}, wantErr: true},
74+ {name: "empty session", key: RemoteSessionKey{Host: "arch"}, wantErr: true},
75+ {name: "unsafe session", key: RemoteSessionKey{Host: "arch", Name: "work/session"}, wantErr: true},
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ err := tt.key.Validate()
81+ if tt.wantErr && err == nil {
82+ t.Fatal("RemoteSessionKey.Validate() = nil, want error")
83+ }
84+ if !tt.wantErr && err != nil {
85+ t.Fatalf("RemoteSessionKey.Validate() = %v, want nil", err)
86+ }
87+ })
88+ }
89+}
90+
91+func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92+ key := RemoteSessionKey{Host: "arch", Name: "hello"}
93+
94+ if got, want := key.Display(), "hello@arch"; got != want {
95+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)
96+ }
97+ if key.Host != "arch" || key.Name != "hello" {
98+ t.Fatalf("Display mutated routing key: %#v", key)
99+ }
100+}
@@ -0,0 +1,100 @@
1+package domain
2+
3+import "testing"
4+
5+func TestRemoteSessionKeyID(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ key RemoteSessionKey
9+ want SessionID
10+ }{
11+ {
12+ name: "uses raw URL base64 components",
13+ key: RemoteSessionKey{Host: "arch", Name: "hello"},
14+ want: "remote:YXJjaA.aGVsbG8",
15+ },
16+ {
17+ name: "preserves host metacharacters without delimiters",
18+ key: RemoteSessionKey{Host: "build@mule:2222/path.[x]", Name: "work.1"},
19+ want: "remote:YnVpbGRAbXVsZToyMjIyL3BhdGguW3hd.d29yay4x",
20+ },
21+ {
22+ name: "supports bracketed IPv6 hosts",
23+ key: RemoteSessionKey{Host: "user@[2001:db8::1]:2222", Name: "work"},
24+ want: "remote:dXNlckBbMjAwMTpkYjg6OjFdOjIyMjI.d29yaw",
25+ },
26+ }
27+
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ if got := tt.key.ID(); got != tt.want {
31+ t.Fatalf("RemoteSessionKey.ID() = %q, want %q", got, tt.want)
32+ }
33+ if got := tt.key.ID(); got != tt.key.ID() {
34+ t.Fatalf("RemoteSessionKey.ID() is unstable: %q != %q", got, tt.key.ID())
35+ }
36+ })
37+ }
38+}
39+
40+func TestRemoteSessionKeyIDAvoidsRawConcatenationCollisions(t *testing.T) {
41+ tests := []struct {
42+ name string
43+ first RemoteSessionKey
44+ last RemoteSessionKey
45+ }{
46+ {
47+ name: "host component delimiter",
48+ first: RemoteSessionKey{Host: "arch.work", Name: "shell"},
49+ last: RemoteSessionKey{Host: "arch", Name: "work.shell"},
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ if tt.first.Host+"."+tt.first.Name != tt.last.Host+"."+tt.last.Name {
56+ t.Fatal("test setup does not collide under raw concatenation")
57+ }
58+ if tt.first.ID() == tt.last.ID() {
59+ t.Fatalf("distinct keys have the same ID: %q", tt.first.ID())
60+ }
61+ })
62+ }
63+}
64+
65+func TestRemoteSessionKeyValidate(t *testing.T) {
66+ tests := []struct {
67+ name string
68+ key RemoteSessionKey
69+ wantErr bool
70+ }{
71+ {name: "valid", key: RemoteSessionKey{Host: "build@mule:2222/path", Name: "work.1"}},
72+ {name: "empty host", key: RemoteSessionKey{Name: "work"}, wantErr: true},
73+ {name: "host whitespace", key: RemoteSessionKey{Host: "build mule", Name: "work"}, wantErr: true},
74+ {name: "empty session", key: RemoteSessionKey{Host: "arch"}, wantErr: true},
75+ {name: "unsafe session", key: RemoteSessionKey{Host: "arch", Name: "work/session"}, wantErr: true},
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ err := tt.key.Validate()
81+ if tt.wantErr && err == nil {
82+ t.Fatal("RemoteSessionKey.Validate() = nil, want error")
83+ }
84+ if !tt.wantErr && err != nil {
85+ t.Fatalf("RemoteSessionKey.Validate() = %v, want nil", err)
86+ }
87+ })
88+ }
89+}
90+
91+func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) {
92+ key := RemoteSessionKey{Host: "arch", Name: "hello"}
93+
94+ if got, want := key.Display(), "hello@arch"; got != want {
95+ t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want)
96+ }
97+ if key.Host != "arch" || key.Name != "hello" {
98+ t.Fatalf("Display mutated routing key: %#v", key)
99+ }
100+}
M internal/ports/mocks/remote_mock.go +133 -0
@@ -636,3 +636,136 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C
636636 _c.Call.Return(run)
637637 return _c
638638 }
639+
640+// NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
641+// The first argument is typically a *testing.T value.
642+func NewMockRemoteCatalogCache(t interface {
643+ mock.TestingT
644+ Cleanup(func())
645+}) *MockRemoteCatalogCache {
646+ mock := &MockRemoteCatalogCache{}
647+ mock.Mock.Test(t)
648+
649+ t.Cleanup(func() { mock.AssertExpectations(t) })
650+
651+ return mock
652+}
653+
654+// MockRemoteCatalogCache is an autogenerated mock type for the RemoteCatalogCache type
655+type MockRemoteCatalogCache struct {
656+ mock.Mock
657+}
658+
659+type MockRemoteCatalogCache_Expecter struct {
660+ mock *mock.Mock
661+}
662+
663+func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter {
664+ return &MockRemoteCatalogCache_Expecter{mock: &_m.Mock}
665+}
666+
667+// Load provides a mock function for the type MockRemoteCatalogCache
668+func (_mock *MockRemoteCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
669+ ret := _mock.Called()
670+
671+ if len(ret) == 0 {
672+ panic("no return value specified for Load")
673+ }
674+
675+ var r0 []ports.RemoteCatalogCacheEntry
676+ var r1 error
677+ if returnFunc, ok := ret.Get(0).(func() ([]ports.RemoteCatalogCacheEntry, error)); ok {
678+ return returnFunc()
679+ }
680+ if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok {
681+ r0 = returnFunc()
682+ } else {
683+ if ret.Get(0) != nil {
684+ r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry)
685+ }
686+ }
687+ if returnFunc, ok := ret.Get(1).(func() error); ok {
688+ r1 = returnFunc()
689+ } else {
690+ r1 = ret.Error(1)
691+ }
692+ return r0, r1
693+}
694+
695+// MockRemoteCatalogCache_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
696+type MockRemoteCatalogCache_Load_Call struct {
697+ *mock.Call
698+}
699+
700+// Load is a helper method to define mock.On call
701+func (_e *MockRemoteCatalogCache_Expecter) Load() *MockRemoteCatalogCache_Load_Call {
702+ return &MockRemoteCatalogCache_Load_Call{Call: _e.mock.On("Load")}
703+}
704+
705+func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCache_Load_Call {
706+ _c.Call.Run(func(args mock.Arguments) {
707+ run()
708+ })
709+ return _c
710+}
711+
712+func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call {
713+ _c.Call.Return(remoteCatalogCacheEntrys, err)
714+ return _c
715+}
716+
717+func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]ports.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call {
718+ _c.Call.Return(run)
719+ return _c
720+}
721+
722+// Store provides a mock function for the type MockRemoteCatalogCache
723+func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error {
724+ ret := _mock.Called(remoteCatalogCacheEntrys)
725+
726+ if len(ret) == 0 {
727+ panic("no return value specified for Store")
728+ }
729+
730+ var r0 error
731+ if returnFunc, ok := ret.Get(0).(func([]ports.RemoteCatalogCacheEntry) error); ok {
732+ r0 = returnFunc(remoteCatalogCacheEntrys)
733+ } else {
734+ r0 = ret.Error(0)
735+ }
736+ return r0
737+}
738+
739+// MockRemoteCatalogCache_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store'
740+type MockRemoteCatalogCache_Store_Call struct {
741+ *mock.Call
742+}
743+
744+// Store is a helper method to define mock.On call
745+// - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry
746+func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call {
747+ return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)}
748+}
749+
750+func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call {
751+ _c.Call.Run(func(args mock.Arguments) {
752+ var arg0 []ports.RemoteCatalogCacheEntry
753+ if args[0] != nil {
754+ arg0 = args[0].([]ports.RemoteCatalogCacheEntry)
755+ }
756+ run(
757+ arg0,
758+ )
759+ })
760+ return _c
761+}
762+
763+func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalogCache_Store_Call {
764+ _c.Call.Return(err)
765+ return _c
766+}
767+
768+func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call {
769+ _c.Call.Return(run)
770+ return _c
771+}
@@ -636,3 +636,136 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C
636 _c.Call.Return(run)636 _c.Call.Return(run)
637 return _c637 return _c
638 }638 }
639+
640+// NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
641+// The first argument is typically a *testing.T value.
642+func NewMockRemoteCatalogCache(t interface {
643+ mock.TestingT
644+ Cleanup(func())
645+}) *MockRemoteCatalogCache {
646+ mock := &MockRemoteCatalogCache{}
647+ mock.Mock.Test(t)
648+
649+ t.Cleanup(func() { mock.AssertExpectations(t) })
650+
651+ return mock
652+}
653+
654+// MockRemoteCatalogCache is an autogenerated mock type for the RemoteCatalogCache type
655+type MockRemoteCatalogCache struct {
656+ mock.Mock
657+}
658+
659+type MockRemoteCatalogCache_Expecter struct {
660+ mock *mock.Mock
661+}
662+
663+func (_m *MockRemoteCatalogCache) EXPECT() *MockRemoteCatalogCache_Expecter {
664+ return &MockRemoteCatalogCache_Expecter{mock: &_m.Mock}
665+}
666+
667+// Load provides a mock function for the type MockRemoteCatalogCache
668+func (_mock *MockRemoteCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
669+ ret := _mock.Called()
670+
671+ if len(ret) == 0 {
672+ panic("no return value specified for Load")
673+ }
674+
675+ var r0 []ports.RemoteCatalogCacheEntry
676+ var r1 error
677+ if returnFunc, ok := ret.Get(0).(func() ([]ports.RemoteCatalogCacheEntry, error)); ok {
678+ return returnFunc()
679+ }
680+ if returnFunc, ok := ret.Get(0).(func() []ports.RemoteCatalogCacheEntry); ok {
681+ r0 = returnFunc()
682+ } else {
683+ if ret.Get(0) != nil {
684+ r0 = ret.Get(0).([]ports.RemoteCatalogCacheEntry)
685+ }
686+ }
687+ if returnFunc, ok := ret.Get(1).(func() error); ok {
688+ r1 = returnFunc()
689+ } else {
690+ r1 = ret.Error(1)
691+ }
692+ return r0, r1
693+}
694+
695+// MockRemoteCatalogCache_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
696+type MockRemoteCatalogCache_Load_Call struct {
697+ *mock.Call
698+}
699+
700+// Load is a helper method to define mock.On call
701+func (_e *MockRemoteCatalogCache_Expecter) Load() *MockRemoteCatalogCache_Load_Call {
702+ return &MockRemoteCatalogCache_Load_Call{Call: _e.mock.On("Load")}
703+}
704+
705+func (_c *MockRemoteCatalogCache_Load_Call) Run(run func()) *MockRemoteCatalogCache_Load_Call {
706+ _c.Call.Run(func(args mock.Arguments) {
707+ run()
708+ })
709+ return _c
710+}
711+
712+func (_c *MockRemoteCatalogCache_Load_Call) Return(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry, err error) *MockRemoteCatalogCache_Load_Call {
713+ _c.Call.Return(remoteCatalogCacheEntrys, err)
714+ return _c
715+}
716+
717+func (_c *MockRemoteCatalogCache_Load_Call) RunAndReturn(run func() ([]ports.RemoteCatalogCacheEntry, error)) *MockRemoteCatalogCache_Load_Call {
718+ _c.Call.Return(run)
719+ return _c
720+}
721+
722+// Store provides a mock function for the type MockRemoteCatalogCache
723+func (_mock *MockRemoteCatalogCache) Store(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error {
724+ ret := _mock.Called(remoteCatalogCacheEntrys)
725+
726+ if len(ret) == 0 {
727+ panic("no return value specified for Store")
728+ }
729+
730+ var r0 error
731+ if returnFunc, ok := ret.Get(0).(func([]ports.RemoteCatalogCacheEntry) error); ok {
732+ r0 = returnFunc(remoteCatalogCacheEntrys)
733+ } else {
734+ r0 = ret.Error(0)
735+ }
736+ return r0
737+}
738+
739+// MockRemoteCatalogCache_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store'
740+type MockRemoteCatalogCache_Store_Call struct {
741+ *mock.Call
742+}
743+
744+// Store is a helper method to define mock.On call
745+// - remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry
746+func (_e *MockRemoteCatalogCache_Expecter) Store(remoteCatalogCacheEntrys any) *MockRemoteCatalogCache_Store_Call {
747+ return &MockRemoteCatalogCache_Store_Call{Call: _e.mock.On("Store", remoteCatalogCacheEntrys)}
748+}
749+
750+func (_c *MockRemoteCatalogCache_Store_Call) Run(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry)) *MockRemoteCatalogCache_Store_Call {
751+ _c.Call.Run(func(args mock.Arguments) {
752+ var arg0 []ports.RemoteCatalogCacheEntry
753+ if args[0] != nil {
754+ arg0 = args[0].([]ports.RemoteCatalogCacheEntry)
755+ }
756+ run(
757+ arg0,
758+ )
759+ })
760+ return _c
761+}
762+
763+func (_c *MockRemoteCatalogCache_Store_Call) Return(err error) *MockRemoteCatalogCache_Store_Call {
764+ _c.Call.Return(err)
765+ return _c
766+}
767+
768+func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalogCacheEntrys []ports.RemoteCatalogCacheEntry) error) *MockRemoteCatalogCache_Store_Call {
769+ _c.Call.Return(run)
770+ return _c
771+}
M internal/ports/remote.go +16 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "log/slog"
7+ "time"
78 )
89
910 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
@@ -46,6 +47,21 @@ type RemoteCatalogClient interface {
4647 List(ctx context.Context, target string) (RemoteCatalog, error)
4748 }
4849
50+// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
51+// immediate remote discovery at daemon startup.
52+type RemoteCatalogCacheEntry struct {
53+ Host string
54+ FetchedAt time.Time
55+ Sessions []RemoteCatalogSession
56+}
57+
58+// RemoteCatalogCache persists complete remote discovery snapshots independently
59+// from the remote host registry.
60+type RemoteCatalogCache interface {
61+ Load() ([]RemoteCatalogCacheEntry, error)
62+ Store([]RemoteCatalogCacheEntry) error
63+}
64+
4965 // RemoteCatalogSession is one live session in the remote discovery catalog.
5066 // State is an explicit string contract (running|stopped|broken), not SessionState.
5167 type RemoteCatalogSession struct {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "log/slog"6 "log/slog"
7+ "time"
7 )8 )
8 9
9 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.10 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
@@ -46,6 +47,21 @@ type RemoteCatalogClient interface {
46 List(ctx context.Context, target string) (RemoteCatalog, error)47 List(ctx context.Context, target string) (RemoteCatalog, error)
47 }48 }
48 49
50+// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
51+// immediate remote discovery at daemon startup.
52+type RemoteCatalogCacheEntry struct {
53+ Host string
54+ FetchedAt time.Time
55+ Sessions []RemoteCatalogSession
56+}
57+
58+// RemoteCatalogCache persists complete remote discovery snapshots independently
59+// from the remote host registry.
60+type RemoteCatalogCache interface {
61+ Load() ([]RemoteCatalogCacheEntry, error)
62+ Store([]RemoteCatalogCacheEntry) error
63+}
64+
49 // RemoteCatalogSession is one live session in the remote discovery catalog.65 // RemoteCatalogSession is one live session in the remote discovery catalog.
50 // State is an explicit string contract (running|stopped|broken), not SessionState.66 // State is an explicit string contract (running|stopped|broken), not SessionState.
51 type RemoteCatalogSession struct {67 type RemoteCatalogSession struct {
M internal/usecase/daemon/daemon.go +22 -0
@@ -296,6 +296,14 @@ type Daemon struct {
296296 barScripts *barScriptState
297297 notices *noticeCenter
298298 resumeParkGrace time.Duration
299+ // remoteCatalog owns cache-derived discovery state independently of the
300+ // live attachment registry. Its cache reads and writes never hold d.mu.
301+ remoteCatalog remoteCatalogState
302+ remoteHostStore ports.RemoteHostStore
303+ remoteCatalogClient ports.RemoteCatalogClient
304+ remoteCatalogCache ports.RemoteCatalogCache
305+ remoteDialerFactory ports.RemoteDialerFactory
306+ remoteTransportMode ports.RemoteTransportMode
299307 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
300308 // (see clipboard.go); empty means use os.TempDir().
301309 tempDir string
@@ -372,6 +380,18 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
372380 return func(d *Daemon) { d.runtimeObserver = observer }
373381 }
374382
383+// WithRemoteDiscovery installs the remote discovery ports used by the daemon.
384+// The composition root validates mode before constructing the daemon.
385+func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option {
386+ return func(d *Daemon) {
387+ d.remoteHostStore = store
388+ d.remoteCatalogClient = catalog
389+ d.remoteCatalogCache = cache
390+ d.remoteDialerFactory = dialer
391+ d.remoteTransportMode = mode
392+ }
393+}
394+
375395 // WithShell overrides the command (and its args) each session spawns. The
376396 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
377397 // deterministic program.
@@ -596,6 +616,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
596616 snapshotAdmitted: make(map[*snapshotCapture]struct{}),
597617 snapshotWake: make(chan struct{}, 1),
598618 notices: newNoticeCenter(),
619+ remoteCatalog: newRemoteCatalogState(),
599620 resumeParkGrace: defaultResumeParkGrace,
600621 barScripts: &barScriptState{
601622 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -653,6 +674,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
653674 d.lastAllocatedCreatedAt = maxCreatedAt
654675 }
655676 d.mruSeq.Store(maxSeq)
677+ d.loadRemoteCatalogCache()
656678 return d
657679 }
658680
@@ -296,6 +296,14 @@ type Daemon struct {
296 barScripts *barScriptState296 barScripts *barScriptState
297 notices *noticeCenter297 notices *noticeCenter
298 resumeParkGrace time.Duration298 resumeParkGrace time.Duration
299+ // remoteCatalog owns cache-derived discovery state independently of the
300+ // live attachment registry. Its cache reads and writes never hold d.mu.
301+ remoteCatalog remoteCatalogState
302+ remoteHostStore ports.RemoteHostStore
303+ remoteCatalogClient ports.RemoteCatalogClient
304+ remoteCatalogCache ports.RemoteCatalogCache
305+ remoteDialerFactory ports.RemoteDialerFactory
306+ remoteTransportMode ports.RemoteTransportMode
299 // tempDir overrides os.TempDir() for clipboard-image-transfer writes307 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
300 // (see clipboard.go); empty means use os.TempDir().308 // (see clipboard.go); empty means use os.TempDir().
301 tempDir string309 tempDir string
@@ -372,6 +380,18 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
372 return func(d *Daemon) { d.runtimeObserver = observer }380 return func(d *Daemon) { d.runtimeObserver = observer }
373 }381 }
374 382
383+// WithRemoteDiscovery installs the remote discovery ports used by the daemon.
384+// The composition root validates mode before constructing the daemon.
385+func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache, dialer ports.RemoteDialerFactory, mode ports.RemoteTransportMode) Option {
386+ return func(d *Daemon) {
387+ d.remoteHostStore = store
388+ d.remoteCatalogClient = catalog
389+ d.remoteCatalogCache = cache
390+ d.remoteDialerFactory = dialer
391+ d.remoteTransportMode = mode
392+ }
393+}
394+
375 // WithShell overrides the command (and its args) each session spawns. The395 // WithShell overrides the command (and its args) each session spawns. The
376 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a396 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
377 // deterministic program.397 // deterministic program.
@@ -596,6 +616,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
596 snapshotAdmitted: make(map[*snapshotCapture]struct{}),616 snapshotAdmitted: make(map[*snapshotCapture]struct{}),
597 snapshotWake: make(chan struct{}, 1),617 snapshotWake: make(chan struct{}, 1),
598 notices: newNoticeCenter(),618 notices: newNoticeCenter(),
619+ remoteCatalog: newRemoteCatalogState(),
599 resumeParkGrace: defaultResumeParkGrace,620 resumeParkGrace: defaultResumeParkGrace,
600 barScripts: &barScriptState{621 barScripts: &barScriptState{
601 cfg: barConfigFromDomain(domain.Defaults().Bar),622 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -653,6 +674,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
653 d.lastAllocatedCreatedAt = maxCreatedAt674 d.lastAllocatedCreatedAt = maxCreatedAt
654 }675 }
655 d.mruSeq.Store(maxSeq)676 d.mruSeq.Store(maxSeq)
677+ d.loadRemoteCatalogCache()
656 return d678 return d
657 }679 }
658 680
A internal/usecase/daemon/remote_catalog_state.go +102 -0
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type remoteHostStatus uint8
12+
13+const (
14+ remoteHostCached remoteHostStatus = iota
15+ remoteHostRefreshing
16+ remoteHostFresh
17+ remoteHostUnreachable
18+ remoteHostVersionMismatch
19+)
20+
21+// remoteCatalogState owns the cache-derived discovery state. Its mutex never
22+// covers cache or transport I/O; writeMu only serializes complete cache writes.
23+type remoteCatalogState struct {
24+ mu sync.Mutex
25+ cache map[string]ports.RemoteCatalogCacheEntry
26+ status map[string]remoteHostStatus
27+ failure map[string]time.Time
28+ writeMu sync.Mutex
29+}
30+
31+func newRemoteCatalogState() remoteCatalogState {
32+ return remoteCatalogState{
33+ cache: make(map[string]ports.RemoteCatalogCacheEntry),
34+ status: make(map[string]remoteHostStatus),
35+ failure: make(map[string]time.Time),
36+ }
37+}
38+
39+func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
40+ return ports.RemoteCatalogCacheEntry{
41+ Host: entry.Host,
42+ FetchedAt: entry.FetchedAt,
43+ Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),
44+ }
45+}
46+
47+func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) {
48+ if s == nil {
49+ return
50+ }
51+ s.mu.Lock()
52+ defer s.mu.Unlock()
53+ s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries))
54+ s.status = make(map[string]remoteHostStatus, len(entries))
55+ s.failure = make(map[string]time.Time)
56+ for _, entry := range entries {
57+ s.cache[entry.Host] = cloneRemoteCatalogEntry(entry)
58+ s.status[entry.Host] = remoteHostCached
59+ }
60+}
61+
62+func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry {
63+ if s == nil {
64+ return nil
65+ }
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache))
69+ for _, entry := range s.cache {
70+ entries = append(entries, cloneRemoteCatalogEntry(entry))
71+ }
72+ sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host })
73+ return entries
74+}
75+
76+func (d *Daemon) loadRemoteCatalogCache() {
77+ if d == nil || d.remoteCatalogCache == nil {
78+ return
79+ }
80+ entries, err := d.remoteCatalogCache.Load()
81+ if err != nil {
82+ d.log.Warn("loading remote catalog cache failed; starting empty", "err", err)
83+ return
84+ }
85+ d.remoteCatalog.replaceCache(entries)
86+}
87+
88+// persistRemoteCatalogCache writes the newest complete cache snapshot. Cache
89+// I/O happens after releasing the state mutex and remains serialized with
90+// other writes so an older snapshot cannot win a later atomic replacement.
91+func (d *Daemon) persistRemoteCatalogCache() {
92+ if d == nil || d.remoteCatalogCache == nil {
93+ return
94+ }
95+ d.remoteCatalog.writeMu.Lock()
96+ defer d.remoteCatalog.writeMu.Unlock()
97+
98+ entries := d.remoteCatalog.cacheSnapshot()
99+ if err := d.remoteCatalogCache.Store(entries); err != nil {
100+ d.log.Warn("storing remote catalog cache failed", "err", err)
101+ }
102+}
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "sort"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+type remoteHostStatus uint8
12+
13+const (
14+ remoteHostCached remoteHostStatus = iota
15+ remoteHostRefreshing
16+ remoteHostFresh
17+ remoteHostUnreachable
18+ remoteHostVersionMismatch
19+)
20+
21+// remoteCatalogState owns the cache-derived discovery state. Its mutex never
22+// covers cache or transport I/O; writeMu only serializes complete cache writes.
23+type remoteCatalogState struct {
24+ mu sync.Mutex
25+ cache map[string]ports.RemoteCatalogCacheEntry
26+ status map[string]remoteHostStatus
27+ failure map[string]time.Time
28+ writeMu sync.Mutex
29+}
30+
31+func newRemoteCatalogState() remoteCatalogState {
32+ return remoteCatalogState{
33+ cache: make(map[string]ports.RemoteCatalogCacheEntry),
34+ status: make(map[string]remoteHostStatus),
35+ failure: make(map[string]time.Time),
36+ }
37+}
38+
39+func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
40+ return ports.RemoteCatalogCacheEntry{
41+ Host: entry.Host,
42+ FetchedAt: entry.FetchedAt,
43+ Sessions: append([]ports.RemoteCatalogSession(nil), entry.Sessions...),
44+ }
45+}
46+
47+func (s *remoteCatalogState) replaceCache(entries []ports.RemoteCatalogCacheEntry) {
48+ if s == nil {
49+ return
50+ }
51+ s.mu.Lock()
52+ defer s.mu.Unlock()
53+ s.cache = make(map[string]ports.RemoteCatalogCacheEntry, len(entries))
54+ s.status = make(map[string]remoteHostStatus, len(entries))
55+ s.failure = make(map[string]time.Time)
56+ for _, entry := range entries {
57+ s.cache[entry.Host] = cloneRemoteCatalogEntry(entry)
58+ s.status[entry.Host] = remoteHostCached
59+ }
60+}
61+
62+func (s *remoteCatalogState) cacheSnapshot() []ports.RemoteCatalogCacheEntry {
63+ if s == nil {
64+ return nil
65+ }
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ entries := make([]ports.RemoteCatalogCacheEntry, 0, len(s.cache))
69+ for _, entry := range s.cache {
70+ entries = append(entries, cloneRemoteCatalogEntry(entry))
71+ }
72+ sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host })
73+ return entries
74+}
75+
76+func (d *Daemon) loadRemoteCatalogCache() {
77+ if d == nil || d.remoteCatalogCache == nil {
78+ return
79+ }
80+ entries, err := d.remoteCatalogCache.Load()
81+ if err != nil {
82+ d.log.Warn("loading remote catalog cache failed; starting empty", "err", err)
83+ return
84+ }
85+ d.remoteCatalog.replaceCache(entries)
86+}
87+
88+// persistRemoteCatalogCache writes the newest complete cache snapshot. Cache
89+// I/O happens after releasing the state mutex and remains serialized with
90+// other writes so an older snapshot cannot win a later atomic replacement.
91+func (d *Daemon) persistRemoteCatalogCache() {
92+ if d == nil || d.remoteCatalogCache == nil {
93+ return
94+ }
95+ d.remoteCatalog.writeMu.Lock()
96+ defer d.remoteCatalog.writeMu.Unlock()
97+
98+ entries := d.remoteCatalog.cacheSnapshot()
99+ if err := d.remoteCatalogCache.Store(entries); err != nil {
100+ d.log.Warn("storing remote catalog cache failed", "err", err)
101+ }
102+}
A internal/usecase/daemon/remote_catalog_state_test.go +128 -0
@@ -0,0 +1,128 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type catalogCacheStub struct {
16+ load func() ([]ports.RemoteCatalogCacheEntry, error)
17+ store func([]ports.RemoteCatalogCacheEntry) error
18+}
19+
20+func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
21+ if s.load == nil {
22+ return nil, nil
23+ }
24+ return s.load()
25+}
26+
27+func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error {
28+ if s.store == nil {
29+ return nil
30+ }
31+ return s.store(entries)
32+}
33+
34+func TestRemoteCatalogCacheStartup(t *testing.T) {
35+ now := time.Unix(0, 42)
36+ tests := []struct {
37+ name string
38+ load func() ([]ports.RemoteCatalogCacheEntry, error)
39+ wantCache map[string]ports.RemoteCatalogCacheEntry
40+ wantStatus map[string]remoteHostStatus
41+ }{
42+ {
43+ name: "loads cached entries",
44+ load: func() ([]ports.RemoteCatalogCacheEntry, error) {
45+ return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil
46+ },
47+ wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}},
48+ wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
49+ },
50+ {
51+ name: "invalid cache starts empty",
52+ load: func() ([]ports.RemoteCatalogCacheEntry, error) {
53+ return nil, errors.New("invalid cache")
54+ },
55+ wantCache: map[string]ports.RemoteCatalogCacheEntry{},
56+ wantStatus: map[string]remoteHostStatus{},
57+ },
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}, nil, ports.RemoteTransportUDP))
63+
64+ d.remoteCatalog.mu.Lock()
65+ defer d.remoteCatalog.mu.Unlock()
66+ require.Equal(t, tt.wantCache, d.remoteCatalog.cache)
67+ require.Equal(t, tt.wantStatus, d.remoteCatalog.status)
68+ })
69+ }
70+}
71+
72+func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
73+ var d *Daemon
74+ entered := make(chan struct{}, 2)
75+ release := make(chan struct{})
76+ var active, maxActive int
77+ var activeMu sync.Mutex
78+
79+ cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error {
80+ if !d.mu.TryLock() {
81+ return errors.New("daemon lock held during cache I/O")
82+ }
83+ d.mu.Unlock()
84+ if !d.remoteCatalog.mu.TryLock() {
85+ return errors.New("remote catalog lock held during cache I/O")
86+ }
87+ d.remoteCatalog.mu.Unlock()
88+
89+ activeMu.Lock()
90+ active++
91+ if active > maxActive {
92+ maxActive = active
93+ }
94+ activeMu.Unlock()
95+ entered <- struct{}{}
96+ <-release
97+ activeMu.Lock()
98+ active--
99+ activeMu.Unlock()
100+ return nil
101+ }}
102+ d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))
103+ d.remoteCatalog.mu.Lock()
104+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "running"}}}
105+ d.remoteCatalog.mu.Unlock()
106+
107+ done := make(chan struct{}, 2)
108+ go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109+ <-entered
110+ d.remoteCatalog.mu.Lock()
111+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "running"}}}
112+ d.remoteCatalog.mu.Unlock()
113+ go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114+
115+ select {
116+ case <-entered:
117+ t.Fatal("concurrent cache stores were not serialized")
118+ case <-time.After(10 * time.Millisecond):
119+ }
120+ close(release)
121+ <-entered
122+ <-done
123+ <-done
124+
125+ activeMu.Lock()
126+ defer activeMu.Unlock()
127+ require.Equal(t, 1, maxActive)
128+}
@@ -0,0 +1,128 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type catalogCacheStub struct {
16+ load func() ([]ports.RemoteCatalogCacheEntry, error)
17+ store func([]ports.RemoteCatalogCacheEntry) error
18+}
19+
20+func (s catalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
21+ if s.load == nil {
22+ return nil, nil
23+ }
24+ return s.load()
25+}
26+
27+func (s catalogCacheStub) Store(entries []ports.RemoteCatalogCacheEntry) error {
28+ if s.store == nil {
29+ return nil
30+ }
31+ return s.store(entries)
32+}
33+
34+func TestRemoteCatalogCacheStartup(t *testing.T) {
35+ now := time.Unix(0, 42)
36+ tests := []struct {
37+ name string
38+ load func() ([]ports.RemoteCatalogCacheEntry, error)
39+ wantCache map[string]ports.RemoteCatalogCacheEntry
40+ wantStatus map[string]remoteHostStatus
41+ }{
42+ {
43+ name: "loads cached entries",
44+ load: func() ([]ports.RemoteCatalogCacheEntry, error) {
45+ return []ports.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}}, nil
46+ },
47+ wantCache: map[string]ports.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}}}},
48+ wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
49+ },
50+ {
51+ name: "invalid cache starts empty",
52+ load: func() ([]ports.RemoteCatalogCacheEntry, error) {
53+ return nil, errors.New("invalid cache")
54+ },
55+ wantCache: map[string]ports.RemoteCatalogCacheEntry{},
56+ wantStatus: map[string]remoteHostStatus{},
57+ },
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}, nil, ports.RemoteTransportUDP))
63+
64+ d.remoteCatalog.mu.Lock()
65+ defer d.remoteCatalog.mu.Unlock()
66+ require.Equal(t, tt.wantCache, d.remoteCatalog.cache)
67+ require.Equal(t, tt.wantStatus, d.remoteCatalog.status)
68+ })
69+ }
70+}
71+
72+func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
73+ var d *Daemon
74+ entered := make(chan struct{}, 2)
75+ release := make(chan struct{})
76+ var active, maxActive int
77+ var activeMu sync.Mutex
78+
79+ cache := catalogCacheStub{store: func(entries []ports.RemoteCatalogCacheEntry) error {
80+ if !d.mu.TryLock() {
81+ return errors.New("daemon lock held during cache I/O")
82+ }
83+ d.mu.Unlock()
84+ if !d.remoteCatalog.mu.TryLock() {
85+ return errors.New("remote catalog lock held during cache I/O")
86+ }
87+ d.remoteCatalog.mu.Unlock()
88+
89+ activeMu.Lock()
90+ active++
91+ if active > maxActive {
92+ maxActive = active
93+ }
94+ activeMu.Unlock()
95+ entered <- struct{}{}
96+ <-release
97+ activeMu.Lock()
98+ active--
99+ activeMu.Unlock()
100+ return nil
101+ }}
102+ d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache, nil, ports.RemoteTransportUDP))
103+ d.remoteCatalog.mu.Lock()
104+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []ports.RemoteCatalogSession{{Name: "one", State: "running"}}}
105+ d.remoteCatalog.mu.Unlock()
106+
107+ done := make(chan struct{}, 2)
108+ go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109+ <-entered
110+ d.remoteCatalog.mu.Lock()
111+ d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []ports.RemoteCatalogSession{{Name: "two", State: "running"}}}
112+ d.remoteCatalog.mu.Unlock()
113+ go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114+
115+ select {
116+ case <-entered:
117+ t.Fatal("concurrent cache stores were not serialized")
118+ case <-time.After(10 * time.Millisecond):
119+ }
120+ close(release)
121+ <-entered
122+ <-done
123+ <-done
124+
125+ activeMu.Lock()
126+ defer activeMu.Unlock()
127+ require.Equal(t, 1, maxActive)
128+}

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