1package remote
2
3import (
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
18const (
19 catalogCacheFileName = "remote-catalog-cache.json"
20 catalogCacheFileVersion = 2
21)
22
23// CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
24func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) }
25
26type catalogCacheFile struct {
27 Version *int `json:"version"`
28 Hosts *[]catalogCacheHost `json:"hosts"`
29}
30
31type catalogCacheHost struct {
32 Target *string `json:"target"`
33 FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
34 Sessions *[]catalogCacheSession `json:"sessions"`
35}
36
37type catalogCacheSession struct {
38 LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
39 Name *string `json:"name"`
40 State *string `json:"state"`
41 Ephemeral *bool `json:"ephemeral"`
42 LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43 Tabs *uint16 `json:"tabs,omitempty"`
44 TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
45 ActiveTabID *string `json:"active_tab_id,omitempty"`
46 Attached *bool `json:"attached"`
47}
48
49type fileCatalogCache struct {
50 path string
51}
52
53// NewFileCatalogCache returns a durable remote catalog cache backed by path.
54func NewFileCatalogCache(path string) ports.RemoteCatalogCache {
55 return &fileCatalogCache{path: path}
56}
57
58var _ ports.RemoteCatalogCache = (*fileCatalogCache)(nil)
59
60// Load reads and validates one complete cache snapshot. A missing cache is an
61// empty snapshot; malformed cache data is never changed by Load.
62func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
63 raw, err := os.ReadFile(c.path)
64 if errors.Is(err, os.ErrNotExist) {
65 return []ports.RemoteCatalogCacheEntry{}, nil
66 }
67 if err != nil {
68 return nil, err
69 }
70 if !utf8.Valid(raw) {
71 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
72 }
73
74 dec := json.NewDecoder(bytes.NewReader(raw))
75 dec.DisallowUnknownFields()
76 var file catalogCacheFile
77 if err := dec.Decode(&file); err != nil {
78 return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
79 }
80 if err := rejectTrailingJSON(dec); err != nil {
81 return nil, fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
82 }
83 if file.Version == nil {
84 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
85 }
86 if *file.Version != catalogCacheFileVersion {
87 return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *file.Version)
88 }
89 if file.Hosts == nil {
90 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
91 }
92
93 entries := make([]ports.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
94 for _, host := range *file.Hosts {
95 if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
96 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
97 }
98 if !utf8.ValidString(*host.Target) {
99 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
100 }
101 if *host.FetchedAtUnixNano <= 0 {
102 return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
103 }
104 entry := ports.RemoteCatalogCacheEntry{
105 Host: *host.Target,
106 FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
107 Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
108 }
109 for _, session := range *host.Sessions {
110 if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil {
111 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
112 }
113 if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
114 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
115 }
116 decoded := ports.RemoteCatalogSession{
117 Name: *session.Name,
118 State: *session.State,
119 Ephemeral: *session.Ephemeral,
120 Attached: *session.Attached,
121 }
122 if session.LastUsedSeq != nil {
123 decoded.LastUsedSeq = *session.LastUsedSeq
124 }
125 if session.LifecycleID != nil {
126 decoded.LifecycleID = *session.LifecycleID
127 }
128 if session.ActiveTabID != nil {
129 decoded.ActiveTabID = *session.ActiveTabID
130 }
131 if session.TabList != nil {
132 decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...)
133 } else {
134 decoded.Tabs = int(*session.Tabs)
135 }
136 entry.Sessions = append(entry.Sessions, decoded)
137 }
138 entries = append(entries, entry)
139 }
140 normalized, err := normalizeCatalogCacheEntries(entries)
141 if err != nil {
142 return nil, err
143 }
144 return normalized, nil
145}
146
147// Store atomically replaces the cache with a complete normalized snapshot.
148func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
149 normalized, err := normalizeCatalogCacheEntries(entries)
150 if err != nil {
151 return err
152 }
153 hosts := make([]catalogCacheHost, 0, len(normalized))
154 for _, entry := range normalized {
155 target := entry.Host
156 fetchedAt := entry.FetchedAt.UnixNano()
157 sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
158 for _, session := range entry.Sessions {
159 name := session.Name
160 state := session.State
161 ephemeral := session.Ephemeral
162 attached := session.Attached
163 row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached}
164 if session.LastUsedSeq != 0 {
165 lastUsed := session.LastUsedSeq
166 row.LastUsedSeq = &lastUsed
167 }
168 if session.LifecycleID != (domain.SessionLifecycleID{}) {
169 id := session.LifecycleID
170 row.LifecycleID = &id
171 }
172 if session.ActiveTabID != "" {
173 active := session.ActiveTabID
174 row.ActiveTabID = &active
175 }
176 if tabs := ports.CatalogTabs(session); tabs != nil {
177 copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...)
178 row.TabList = ©Tabs
179 } else {
180 count := ports.SaturateUint16(ports.CatalogTabCount(session))
181 row.Tabs = &count
182 }
183 sessions = append(sessions, row)
184 }
185 hosts = append(hosts, catalogCacheHost{
186 Target: &target,
187 FetchedAtUnixNano: &fetchedAt,
188 Sessions: &sessions,
189 })
190 }
191 version := catalogCacheFileVersion
192 payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts})
193 if err != nil {
194 return err
195 }
196 return atomicReplacePrivateFile(c.path, append(payload, '\n'))
197}
198
199func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
200 if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil {
201 return nil, err
202 }
203 normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
204 hosts := make(map[string]struct{}, len(entries))
205 for _, entry := range entries {
206 if err := validateHostTarget(entry.Host); err != nil {
207 return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
208 }
209 if entry.FetchedAt.UnixNano() <= 0 {
210 return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
211 }
212 if _, duplicate := hosts[entry.Host]; duplicate {
213 return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
214 }
215 hosts[entry.Host] = struct{}{}
216
217 copyEntry := ports.RemoteCatalogCacheEntry{
218 Host: entry.Host,
219 FetchedAt: entry.FetchedAt,
220 Sessions: make([]ports.RemoteCatalogSession, 0, len(entry.Sessions)),
221 }
222 sessions := make(map[string]struct{}, len(entry.Sessions))
223 for _, session := range entry.Sessions {
224 if !utf8.ValidString(session.Name) || !utf8.ValidString(session.State) {
225 return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
226 }
227 if err := domain.ValidateSessionName(session.Name); err != nil {
228 return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
229 }
230 if !validCatalogState(session.State) {
231 return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
232 }
233 if _, duplicate := sessions[session.Name]; duplicate {
234 return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
235 }
236 sessions[session.Name] = struct{}{}
237 copySession := session
238 if tabs := ports.CatalogTabs(session); tabs != nil {
239 copyTabs := make([]ports.RemoteCatalogTab, len(tabs))
240 for i, tab := range tabs {
241 copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
242 }
243 copySession.Tabs = copyTabs
244 }
245 copyEntry.Sessions = append(copyEntry.Sessions, copySession)
246 }
247 sort.Slice(copyEntry.Sessions, func(i, j int) bool {
248 return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
249 })
250 normalized = append(normalized, copyEntry)
251 }
252 sort.Slice(normalized, func(i, j int) bool {
253 return normalized[i].Host < normalized[j].Host
254 })
255 return normalized, nil
256}