1package ports
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "math"
10 "slices"
11 "time"
12 "unicode"
13 "unicode/utf8"
14
15 "github.com/bnema/vev/internal/domain"
16)
17
18// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
19type RemoteTransportMode string
20
21const (
22 RemoteTransportUDP RemoteTransportMode = "udp"
23 RemoteTransportStdio RemoteTransportMode = "stdio"
24)
25
26// RemoteDialerFactory builds the transport-specific dialer for a remote target.
27// Implementations live in adapters; app wiring consumes this interface.
28type RemoteDialerFactory interface {
29 DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
30}
31
32// RemoteHostStore persists pinned and learned remote host targets in one
33// versioned state file across processes.
34type RemoteHostStore interface {
35 // Hosts returns pinned hosts in stored order and learned hosts in lexical order.
36 Hosts() (pinned, learned []string, err error)
37 AddPinned(target string) error
38 RemovePinned(target string) error
39 Remember(target string) error
40 Forget(target string) error
41 // Remove deletes target from both pinned and learned lists atomically and
42 // reports whether the target existed in either list.
43 Remove(target string) (deleted bool, err error)
44}
45
46// RemoteHostLearner records the remote target after a successful attach.
47// It deliberately has no arguments because app captures the validated target
48// while constructing the client dependency.
49type RemoteHostLearner interface {
50 RememberRemoteHost() error
51}
52
53// RemoteCatalogClient fetches a versioned session catalog from a remote host.
54type RemoteCatalogClient interface {
55 List(ctx context.Context, target string) (RemoteCatalog, error)
56}
57
58// RemotePreviewClient fetches one bounded, exact-target viewport without
59// attaching to or mutating the remote session.
60type RemotePreviewClient interface {
61 Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (RemotePreview, error)
62}
63
64const (
65 // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66 // catalogue can be rejected without changing the attachment wire layout.
67 RemoteCatalogSchemaVersion uint16 = 2
68
69 RemoteCatalogMaxHosts = 64
70 RemoteCatalogMaxSessions = 256
71 RemoteCatalogMaxTabsPerSess = 128
72 RemoteCatalogMaxNameBytes = 256
73 RemoteCatalogMaxDetailBytes = 512
74 RemoteCatalogMaxIDBytes = 128
75 RemoteCatalogMaxCatalogBytes = 1 << 20
76)
77
78// RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
79// immediate remote discovery at daemon startup. The legacy-shaped Sessions
80// field is retained as a source-compatible container, but cache writers strip
81// dynamic Detail and Attention fields before persistence.
82type RemoteCatalogCacheEntry struct {
83 Host string
84 FetchedAt time.Time
85 Sessions []RemoteCatalogSession
86}
87
88// RemoteCatalogCache persists complete remote discovery snapshots independently
89// from the remote host registry.
90type RemoteCatalogCache interface {
91 Load() ([]RemoteCatalogCacheEntry, error)
92 Store([]RemoteCatalogCacheEntry) error
93}
94
95// RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail
96// and Attention are deliberately omitted from the durable cache.
97type RemoteCatalogTab struct {
98 ID string `json:"id"`
99 Index uint16 `json:"index"`
100 Name string `json:"name"`
101 Detail string `json:"detail,omitempty"`
102 Attention bool `json:"attention,omitempty"`
103}
104
105// RemoteCatalogSession is one session in the remote discovery catalog. State
106// is an explicit string contract (up|down|broken), not SessionState.
107//
108// Tabs is interface-typed only to keep old v0.x callers that supplied a count
109// source-compatible while the versioned schema carries []RemoteCatalogTab.
110// New code must use CatalogTabs and never infer routing identity from a count.
111type RemoteCatalogSession struct {
112 LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
113 Name string `json:"name"`
114 State string `json:"state"`
115 Ephemeral bool `json:"ephemeral"`
116 Tabs any `json:"tabs"`
117 Attached bool `json:"attached"`
118 LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
119 ActiveTabID string `json:"active_tab_id,omitempty"`
120 Reason string `json:"reason,omitempty"`
121}
122
123// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.
124// Encoding/json does not apply omitempty to a zero array that implements
125// encoding.TextMarshaler, so the pointer is made explicit here.
126func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) {
127 type envelope struct {
128 LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
129 Name string `json:"name"`
130 State string `json:"state"`
131 Ephemeral bool `json:"ephemeral"`
132 Tabs any `json:"tabs"`
133 Attached bool `json:"attached"`
134 LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
135 ActiveTabID string `json:"active_tab_id,omitempty"`
136 Reason string `json:"reason,omitempty"`
137 }
138 var lifecycle *domain.SessionLifecycleID
139 if s.LifecycleID != (domain.SessionLifecycleID{}) {
140 id := s.LifecycleID
141 lifecycle = &id
142 }
143 return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason})
144}
145
146// UnmarshalJSON preserves the legacy numeric tab-count representation while
147// decoding the current array representation into concrete tab values.
148func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error {
149 type envelope struct {
150 LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
151 Name string `json:"name"`
152 State string `json:"state"`
153 Ephemeral bool `json:"ephemeral"`
154 Tabs json.RawMessage `json:"tabs"`
155 Attached bool `json:"attached"`
156 LastUsedSeq uint64 `json:"last_used_seq"`
157 ActiveTabID string `json:"active_tab_id"`
158 Reason string `json:"reason"`
159 }
160 var raw envelope
161 if err := json.Unmarshal(data, &raw); err != nil {
162 return err
163 }
164 *s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason}
165 if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" {
166 s.Tabs = 0
167 return nil
168 }
169 var tabs []RemoteCatalogTab
170 if err := json.Unmarshal(raw.Tabs, &tabs); err == nil {
171 s.Tabs = tabs
172 return nil
173 }
174 var count uint16
175 if err := json.Unmarshal(raw.Tabs, &count); err != nil {
176 return err
177 }
178 s.Tabs = int(count)
179 return nil
180}
181
182// CatalogTabs returns a copied typed tab list. Legacy count-only entries have
183// no stable tab identity and therefore return nil.
184func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
185 tabs, ok := session.Tabs.([]RemoteCatalogTab)
186 if !ok {
187 return nil
188 }
189 return slices.Clone(tabs)
190}
191
192// CatalogTabCount returns the count represented by either schema form.
193func CatalogTabCount(session RemoteCatalogSession) int {
194 switch tabs := session.Tabs.(type) {
195 case []RemoteCatalogTab:
196 return len(tabs)
197 case uint16:
198 return int(tabs)
199 case uint32:
200 return int(tabs)
201 case int:
202 return tabs
203 case float64:
204 return int(tabs)
205 default:
206 return 0
207 }
208}
209
210// SaturateUint16 clamps n to the uint16 range for legacy command output only.
211func SaturateUint16(n int) uint16 {
212 if n <= 0 {
213 return 0
214 }
215 if n >= 1<<16 {
216 return math.MaxUint16
217 }
218 return uint16(n)
219}
220
221// RemoteCatalog is the independently versioned JSON envelope returned by
222// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers
223// must also provide SchemaVersion.
224type RemoteCatalog struct {
225 ProtocolVersion uint16 `json:"protocol_version"`
226 SchemaVersion uint16 `json:"schema_version,omitempty"`
227 Sessions []RemoteCatalogSession `json:"sessions"`
228}
229
230// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
231// reports an incompatible protocol or catalogue schema version.
232type RemoteCatalogVersionMismatchError struct {
233 Got uint16
234 Want uint16
235 Kind string
236}
237
238func (e *RemoteCatalogVersionMismatchError) Error() string {
239 kind := e.Kind
240 if kind == "" {
241 kind = "protocol"
242 }
243 return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
244}
245
246var (
247 ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog")
248 ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit")
249 ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state")
250 ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
251)
252
253// ValidateRemoteCatalog applies all limits before a catalogue is cached or
254// rendered. It is deliberately strict for the current schema; zero schema is
255// the read-only compatibility form used by pre-parity v0.x peers.
256func ValidateRemoteCatalog(c RemoteCatalog) error {
257 if c.ProtocolVersion != ProtocolVersion {
258 return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
259 }
260 if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {
261 return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
262 }
263 if len(c.Sessions) > RemoteCatalogMaxSessions {
264 return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
265 }
266 seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
267 bytes := 0
268 for _, session := range c.Sessions {
269 if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
270 return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271 }
272 if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
273 return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
274 }
275 if err := domain.ValidateSessionName(session.Name); err != nil {
276 return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277 }
278 if !validRemoteCatalogState(session.State) {
279 return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280 }
281 if session.ActiveTabID != "" {
282 if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
283 return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
284 }
285 }
286 if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287 return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288 }
289 if c.SchemaVersion != 0 {
290 if session.LifecycleID == (domain.SessionLifecycleID{}) {
291 return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
292 }
293 if prior, exists := seen[session.LifecycleID]; exists {
294 return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
295 }
296 seen[session.LifecycleID] = session.Name
297 }
298 tabs := CatalogTabs(session)
299 if len(tabs) == 0 {
300 if c.SchemaVersion != 0 {
301 if session.ActiveTabID != "" {
302 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303 }
304 if session.State == "up" && session.Tabs == nil {
305 return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306 }
307 }
308 } else {
309 if len(tabs) > RemoteCatalogMaxTabsPerSess {
310 return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
311 }
312 tabIDs := make(map[string]struct{}, len(tabs))
313 for i, tab := range tabs {
314 if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) {
315 return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
316 }
317 if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
318 return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
319 }
320 if tab.Index != uint16(i) {
321 return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322 }
323 if session.State == "up" && tab.ID == "" {
324 return fmt.Errorf("%w: up tab has zero ID", ErrInvalidRemoteCatalog)
325 }
326 if tab.ID != "" {
327 if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
328 return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
329 }
330 if _, duplicate := tabIDs[tab.ID]; duplicate {
331 return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
332 }
333 tabIDs[tab.ID] = struct{}{}
334 }
335 bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336 }
337 if session.ActiveTabID != "" {
338 if session.State != "up" {
339 return fmt.Errorf("%w: down or broken session has an active tab", ErrInvalidRemoteCatalog)
340 }
341 if _, ok := tabIDs[session.ActiveTabID]; !ok {
342 return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
343 }
344 }
345 }
346 bytes += len(session.Name) + len(session.State) + len(session.Reason)
347 }
348 if bytes > RemoteCatalogMaxCatalogBytes {
349 return ErrRemoteCatalogTooLarge
350 }
351 return nil
352}
353
354// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it
355// is loaded into picker state or written to disk. A cache may contain legacy
356// count-only sessions, but any complete typed snapshot must carry lifecycle
357// identity and the same tab invariants as a live catalog.
358func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359 if len(entries) > RemoteCatalogMaxHosts {
360 return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
361 }
362 seenHosts := make(map[string]struct{}, len(entries))
363 for _, entry := range entries {
364 if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
365 return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
366 }
367 if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
368 return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
369 }
370 if _, exists := seenHosts[entry.Host]; exists {
371 return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372 }
373 seenHosts[entry.Host] = struct{}{}
374 complete := false
375 for _, session := range entry.Sessions {
376 if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil {
377 complete = true
378 break
379 }
380 }
381 schema := uint16(0)
382 if complete {
383 schema = RemoteCatalogSchemaVersion
384 }
385 catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions}
386 if err := ValidateRemoteCatalog(catalog); err != nil {
387 return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388 }
389 }
390 return nil
391}
392
393func validRemoteCatalogText(value string) bool {
394 if !utf8.ValidString(value) {
395 return false
396 }
397 for _, r := range value {
398 if unicode.IsControl(r) {
399 return false
400 }
401 }
402 return true
403}
404
405func validRemoteCatalogState(state string) bool {
406 switch state {
407 case "up", "down", "broken":
408 return true
409 default:
410 return false
411 }
412}
413
414func validRemoteCatalogReason(reason string) bool {
415 switch reason {
416 case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
417 return true
418 default:
419 return false
420 }
421}